mirror of https://github.com/portainer/portainer
				
				
				
			feat(edge/templates): introduce edge app templates [EE-6209] (#10480)
							parent
							
								
									95d96e1164
								
							
						
					
					
						commit
						e1e90c9c1d
					
				| 
						 | 
				
			
			@ -23,7 +23,7 @@ parserOptions:
 | 
			
		|||
    modules: true
 | 
			
		||||
 | 
			
		||||
rules:
 | 
			
		||||
  no-console: error
 | 
			
		||||
  no-console: warn
 | 
			
		||||
  no-alert: error
 | 
			
		||||
  no-control-regex: 'off'
 | 
			
		||||
  no-empty: warn
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,7 +53,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
 | 
			
		|||
			},
 | 
			
		||||
			SnapshotInterval:         portainer.DefaultSnapshotInterval,
 | 
			
		||||
			EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
 | 
			
		||||
			TemplatesURL:             portainer.DefaultTemplatesURL,
 | 
			
		||||
			TemplatesURL:             "",
 | 
			
		||||
			HelmRepositoryURL:        portainer.DefaultHelmRepositoryURL,
 | 
			
		||||
			UserSessionTimeout:       portainer.DefaultUserSessionTimeout,
 | 
			
		||||
			KubeconfigExpiry:         portainer.DefaultKubeconfigExpiry,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,8 +10,8 @@ import (
 | 
			
		|||
	"github.com/rs/zerolog/log"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (m *Migrator) migrateDockerDesktopExtentionSetting() error {
 | 
			
		||||
	log.Info().Msg("updating docker desktop extention flag in settings")
 | 
			
		||||
func (m *Migrator) migrateDockerDesktopExtensionSetting() error {
 | 
			
		||||
	log.Info().Msg("updating docker desktop extension flag in settings")
 | 
			
		||||
 | 
			
		||||
	isDDExtension := false
 | 
			
		||||
	if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
package migrator
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	portainer "github.com/portainer/portainer/api"
 | 
			
		||||
	"github.com/rs/zerolog/log"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// updateAppTemplatesVersionForDB110 changes the templates URL to be empty if it was never changed
 | 
			
		||||
// from the default value (version 2.0 URL)
 | 
			
		||||
func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
 | 
			
		||||
	log.Info().Msg("updating app templates url to v3.0")
 | 
			
		||||
 | 
			
		||||
	version2URL := "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
 | 
			
		||||
 | 
			
		||||
	settings, err := migrator.settingsService.Settings()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if settings.TemplatesURL == version2URL || settings.TemplatesURL == portainer.DefaultTemplatesURL {
 | 
			
		||||
		settings.TemplatesURL = ""
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return migrator.settingsService.UpdateSettings(settings)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -14,8 +14,10 @@ func (m *Migrator) updateSettingsToDB25() error {
 | 
			
		|||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// to keep the same migration functionality as before 2.20.0, we need to set the templates URL to v2
 | 
			
		||||
	version2URL := "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
 | 
			
		||||
	if legacySettings.TemplatesURL == "" {
 | 
			
		||||
		legacySettings.TemplatesURL = portainer.DefaultTemplatesURL
 | 
			
		||||
		legacySettings.TemplatesURL = version2URL
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -225,10 +225,14 @@ func (m *Migrator) initMigrations() {
 | 
			
		|||
	m.addMigrations("2.18", m.migrateDBVersionToDB90)
 | 
			
		||||
	m.addMigrations("2.19",
 | 
			
		||||
		m.convertSeedToPrivateKeyForDB100,
 | 
			
		||||
		m.migrateDockerDesktopExtentionSetting,
 | 
			
		||||
		m.migrateDockerDesktopExtensionSetting,
 | 
			
		||||
		m.updateEdgeStackStatusForDB100,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	m.addMigrations("2.20",
 | 
			
		||||
		m.updateAppTemplatesVersionForDB110,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	// Add new migrations below...
 | 
			
		||||
	// One function per migration, each versions migration funcs in the same file.
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -645,7 +645,7 @@
 | 
			
		|||
    },
 | 
			
		||||
    "ShowKomposeBuildOption": false,
 | 
			
		||||
    "SnapshotInterval": "5m",
 | 
			
		||||
    "TemplatesURL": "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json",
 | 
			
		||||
    "TemplatesURL": "",
 | 
			
		||||
    "TrustOnFirstConnect": false,
 | 
			
		||||
    "UserSessionTimeout": "8h",
 | 
			
		||||
    "fdoConfiguration": {
 | 
			
		||||
| 
						 | 
				
			
			@ -936,6 +936,6 @@
 | 
			
		|||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "version": {
 | 
			
		||||
    "VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
 | 
			
		||||
    "VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +31,7 @@ type edgeStackFromGitRepositoryPayload struct {
 | 
			
		|||
	// Path to the Stack file inside the Git repository
 | 
			
		||||
	FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
 | 
			
		||||
	// List of identifiers of EdgeGroups
 | 
			
		||||
	EdgeGroups []portainer.EdgeGroupID `example:"1"`
 | 
			
		||||
	EdgeGroups []portainer.EdgeGroupID `example:"1" validate:"required"`
 | 
			
		||||
	// Deployment type to deploy this stack
 | 
			
		||||
	// Valid values are: 0 - 'compose', 1 - 'kubernetes'
 | 
			
		||||
	// compose is enabled only for docker environments
 | 
			
		||||
| 
						 | 
				
			
			@ -85,7 +85,6 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
 | 
			
		|||
// @security ApiKeyAuth
 | 
			
		||||
// @security jwt
 | 
			
		||||
// @produce json
 | 
			
		||||
// @param method query string true "Creation Method" Enums(file,string,repository)
 | 
			
		||||
// @param body body edgeStackFromGitRepositoryPayload true "stack config"
 | 
			
		||||
// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object"
 | 
			
		||||
// @success 200 {object} portainer.EdgeStack
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ package edgetemplates
 | 
			
		|||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"slices"
 | 
			
		||||
 | 
			
		||||
	portainer "github.com/portainer/portainer/api"
 | 
			
		||||
	"github.com/portainer/portainer/api/http/client"
 | 
			
		||||
| 
						 | 
				
			
			@ -51,10 +52,16 @@ func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request)
 | 
			
		|||
		return httperror.InternalServerError("Unable to parse template file", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// We only support version 3 of the template format
 | 
			
		||||
	// this is only a temporary fix until we have custom edge templates
 | 
			
		||||
	if templateFile.Version != "3" {
 | 
			
		||||
		return httperror.InternalServerError("Unsupported template version", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	filteredTemplates := make([]portainer.Template, 0)
 | 
			
		||||
 | 
			
		||||
	for _, template := range templateFile.Templates {
 | 
			
		||||
		if template.Type == portainer.EdgeStackTemplate {
 | 
			
		||||
		if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
 | 
			
		||||
			filteredTemplates = append(filteredTemplates, template)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,8 +26,10 @@ func NewHandler(bouncer security.BouncerService) *Handler {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	h.Handle("/templates",
 | 
			
		||||
		bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
 | 
			
		||||
		bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
 | 
			
		||||
	h.Handle("/templates/{id}/file",
 | 
			
		||||
		bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
 | 
			
		||||
	h.Handle("/templates/file",
 | 
			
		||||
		bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
 | 
			
		||||
		bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
 | 
			
		||||
	return h
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,72 +1,22 @@
 | 
			
		|||
package templates
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"slices"
 | 
			
		||||
 | 
			
		||||
	portainer "github.com/portainer/portainer/api"
 | 
			
		||||
	httperror "github.com/portainer/portainer/pkg/libhttp/error"
 | 
			
		||||
	"github.com/portainer/portainer/pkg/libhttp/request"
 | 
			
		||||
	"github.com/portainer/portainer/pkg/libhttp/response"
 | 
			
		||||
 | 
			
		||||
	"github.com/asaskevich/govalidator"
 | 
			
		||||
	"github.com/rs/zerolog/log"
 | 
			
		||||
	"github.com/segmentio/encoding/json"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type filePayload struct {
 | 
			
		||||
	// URL of a git repository where the file is stored
 | 
			
		||||
	RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
 | 
			
		||||
	// Path to the file inside the git repository
 | 
			
		||||
	ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type fileResponse struct {
 | 
			
		||||
	// The requested file content
 | 
			
		||||
	FileContent string `example:"version:2"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (payload *filePayload) Validate(r *http.Request) error {
 | 
			
		||||
	if govalidator.IsNull(payload.RepositoryURL) {
 | 
			
		||||
		return errors.New("Invalid repository url")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if govalidator.IsNull(payload.ComposeFilePathInRepository) {
 | 
			
		||||
		return errors.New("Invalid file path")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
 | 
			
		||||
	settings, err := handler.DataStore.Settings().Settings()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to retrieve settings from the database", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	resp, err := http.Get(settings.TemplatesURL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to retrieve templates via the network", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	var templates struct {
 | 
			
		||||
		Templates []portainer.Template
 | 
			
		||||
	}
 | 
			
		||||
	err = json.NewDecoder(resp.Body).Decode(&templates)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to parse template file", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, t := range templates.Templates {
 | 
			
		||||
		if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// @id TemplateFile
 | 
			
		||||
// @summary Get a template's file
 | 
			
		||||
// @description Get a template's file
 | 
			
		||||
| 
						 | 
				
			
			@ -76,21 +26,42 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr
 | 
			
		|||
// @security jwt
 | 
			
		||||
// @accept json
 | 
			
		||||
// @produce json
 | 
			
		||||
// @param body body filePayload true "File details"
 | 
			
		||||
// @param id path int true "Template identifier"
 | 
			
		||||
// @success 200 {object} fileResponse "Success"
 | 
			
		||||
// @failure 400 "Invalid request"
 | 
			
		||||
// @failure 500 "Server error"
 | 
			
		||||
// @router /templates/file [post]
 | 
			
		||||
// @router /templates/{id}/file [post]
 | 
			
		||||
func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
 | 
			
		||||
	var payload filePayload
 | 
			
		||||
 | 
			
		||||
	err := request.DecodeAndValidateJSONPayload(r, &payload)
 | 
			
		||||
	id, err := request.RetrieveNumericRouteVariableValue(r, "id")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.BadRequest("Invalid request payload", err)
 | 
			
		||||
		return httperror.BadRequest("Invalid template identifier", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := handler.ifRequestedTemplateExists(&payload); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	templatesResponse, httpErr := handler.fetchTemplates()
 | 
			
		||||
	if httpErr != nil {
 | 
			
		||||
		return httpErr
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	templateIdx := slices.IndexFunc(templatesResponse.Templates, func(template portainer.Template) bool {
 | 
			
		||||
		return template.ID == portainer.TemplateID(id)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	if templateIdx == -1 {
 | 
			
		||||
		return httperror.NotFound("Unable to find a template with the specified identifier", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	template := templatesResponse.Templates[templateIdx]
 | 
			
		||||
 | 
			
		||||
	if template.Type == portainer.ContainerTemplate {
 | 
			
		||||
		return httperror.BadRequest("Invalid template type", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if template.StackFile != "" {
 | 
			
		||||
		return response.JSON(w, fileResponse{FileContent: template.StackFile})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if template.Repository.StackFile == "" || template.Repository.URL == "" {
 | 
			
		||||
		return httperror.BadRequest("Invalid template configuration", nil)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	projectPath, err := handler.FileService.GetTemporaryPath()
 | 
			
		||||
| 
						 | 
				
			
			@ -100,12 +71,12 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
 | 
			
		|||
 | 
			
		||||
	defer handler.cleanUp(projectPath)
 | 
			
		||||
 | 
			
		||||
	err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
 | 
			
		||||
	err = handler.GitService.CloneRepository(projectPath, template.Repository.URL, "", "", "", false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to clone git repository", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
 | 
			
		||||
	fileContent, err := handler.FileService.GetFileContent(projectPath, template.Repository.StackFile)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Failed loading file content", err)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
package templates
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	httperror "github.com/portainer/portainer/pkg/libhttp/error"
 | 
			
		||||
	"github.com/portainer/portainer/pkg/libhttp/request"
 | 
			
		||||
	"github.com/portainer/portainer/pkg/libhttp/response"
 | 
			
		||||
	"github.com/rs/zerolog/log"
 | 
			
		||||
 | 
			
		||||
	"github.com/asaskevich/govalidator"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type filePayload struct {
 | 
			
		||||
	// URL of a git repository where the file is stored
 | 
			
		||||
	RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
 | 
			
		||||
	// Path to the file inside the git repository
 | 
			
		||||
	ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (payload *filePayload) Validate(r *http.Request) error {
 | 
			
		||||
	if govalidator.IsNull(payload.RepositoryURL) {
 | 
			
		||||
		return errors.New("Invalid repository url")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if govalidator.IsNull(payload.ComposeFilePathInRepository) {
 | 
			
		||||
		return errors.New("Invalid file path")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
 | 
			
		||||
	response, httpErr := handler.fetchTemplates()
 | 
			
		||||
	if httpErr != nil {
 | 
			
		||||
		return httpErr
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, t := range response.Templates {
 | 
			
		||||
		if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// @id TemplateFileOld
 | 
			
		||||
// @summary Get a template's file
 | 
			
		||||
// @deprecated
 | 
			
		||||
// @description Get a template's file
 | 
			
		||||
// @description **Access policy**: authenticated
 | 
			
		||||
// @tags templates
 | 
			
		||||
// @security ApiKeyAuth
 | 
			
		||||
// @security jwt
 | 
			
		||||
// @accept json
 | 
			
		||||
// @produce json
 | 
			
		||||
// @param body body filePayload true "File details"
 | 
			
		||||
// @success 200 {object} fileResponse "Success"
 | 
			
		||||
// @failure 400 "Invalid request"
 | 
			
		||||
// @failure 500 "Server error"
 | 
			
		||||
// @router /templates/file [post]
 | 
			
		||||
func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
 | 
			
		||||
	log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
 | 
			
		||||
 | 
			
		||||
	var payload filePayload
 | 
			
		||||
	err := request.DecodeAndValidateJSONPayload(r, &payload)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.BadRequest("Invalid request payload", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := handler.ifRequestedTemplateExists(&payload); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	projectPath, err := handler.FileService.GetTemporaryPath()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to create temporary folder", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer handler.cleanUp(projectPath)
 | 
			
		||||
 | 
			
		||||
	err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to clone git repository", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Failed loading file content", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return response.JSON(w, fileResponse{FileContent: string(fileContent)})
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,19 +1,12 @@
 | 
			
		|||
package templates
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	portainer "github.com/portainer/portainer/api"
 | 
			
		||||
	httperror "github.com/portainer/portainer/pkg/libhttp/error"
 | 
			
		||||
	"github.com/portainer/portainer/pkg/libhttp/response"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// introduced for swagger
 | 
			
		||||
type listResponse struct {
 | 
			
		||||
	Version   string
 | 
			
		||||
	Templates []portainer.Template
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// @id TemplateList
 | 
			
		||||
// @summary List available templates
 | 
			
		||||
// @description List available templates.
 | 
			
		||||
| 
						 | 
				
			
			@ -26,22 +19,10 @@ type listResponse struct {
 | 
			
		|||
// @failure 500 "Server error"
 | 
			
		||||
// @router /templates [get]
 | 
			
		||||
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
 | 
			
		||||
	settings, err := handler.DataStore.Settings().Settings()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to retrieve settings from the database", err)
 | 
			
		||||
	templates, httpErr := handler.fetchTemplates()
 | 
			
		||||
	if httpErr != nil {
 | 
			
		||||
		return httpErr
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	resp, err := http.Get(settings.TemplatesURL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to retrieve templates via the network", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
	_, err = io.Copy(w, resp.Body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return httperror.InternalServerError("Unable to write templates from templates URL", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
	return response.JSON(w, templates)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
package templates
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	portainer "github.com/portainer/portainer/api"
 | 
			
		||||
	httperror "github.com/portainer/portainer/pkg/libhttp/error"
 | 
			
		||||
	"github.com/segmentio/encoding/json"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type listResponse struct {
 | 
			
		||||
	Version   string               `json:"version"`
 | 
			
		||||
	Templates []portainer.Template `json:"templates"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError) {
 | 
			
		||||
	settings, err := handler.DataStore.Settings().Settings()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	templatesURL := settings.TemplatesURL
 | 
			
		||||
	if templatesURL == "" {
 | 
			
		||||
		templatesURL = portainer.DefaultTemplatesURL
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	resp, err := http.Get(templatesURL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer resp.Body.Close()
 | 
			
		||||
 | 
			
		||||
	var body *listResponse
 | 
			
		||||
	err = json.NewDecoder(resp.Body).Decode(&body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, httperror.InternalServerError("Unable to parse template file", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return body, nil
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1153,7 +1153,7 @@ type (
 | 
			
		|||
	Template struct {
 | 
			
		||||
		// Mandatory container/stack fields
 | 
			
		||||
		// Template Identifier
 | 
			
		||||
		ID TemplateID `json:"Id" example:"1"`
 | 
			
		||||
		ID TemplateID `json:"id" example:"1"`
 | 
			
		||||
		// Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack)
 | 
			
		||||
		Type TemplateType `json:"type" example:"1"`
 | 
			
		||||
		// Title of the template
 | 
			
		||||
| 
						 | 
				
			
			@ -1614,7 +1614,7 @@ const (
 | 
			
		|||
	// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
 | 
			
		||||
	DefaultEdgeAgentCheckinIntervalInSeconds = 5
 | 
			
		||||
	// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
 | 
			
		||||
	DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
 | 
			
		||||
	DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3.0/templates.json"
 | 
			
		||||
	// DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
 | 
			
		||||
	DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
 | 
			
		||||
	// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
 | 
			
		||||
| 
						 | 
				
			
			@ -1829,8 +1829,6 @@ const (
 | 
			
		|||
	SwarmStackTemplate
 | 
			
		||||
	// ComposeStackTemplate represents a template used to deploy a Compose stack
 | 
			
		||||
	ComposeStackTemplate
 | 
			
		||||
	// EdgeStackTemplate represents a template used to deploy an Edge stack
 | 
			
		||||
	EdgeStackTemplate
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -122,17 +122,13 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
 | 
			
		|||
 | 
			
		||||
    const customTemplatesNew = {
 | 
			
		||||
      name: 'docker.templates.custom.new',
 | 
			
		||||
      url: '/new?fileContent&type',
 | 
			
		||||
      url: '/new?appTemplateId&type',
 | 
			
		||||
 | 
			
		||||
      views: {
 | 
			
		||||
        'content@': {
 | 
			
		||||
          component: 'createCustomTemplateView',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      params: {
 | 
			
		||||
        fileContent: '',
 | 
			
		||||
        type: '',
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const customTemplatesEdit = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -142,6 +142,16 @@ angular
 | 
			
		|||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    $stateRegistryProvider.register({
 | 
			
		||||
      name: 'edge.templates',
 | 
			
		||||
      url: '/templates?template',
 | 
			
		||||
      views: {
 | 
			
		||||
        'content@': {
 | 
			
		||||
          component: 'edgeAppTemplatesView',
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    $stateRegistryProvider.register(edge);
 | 
			
		||||
 | 
			
		||||
    $stateRegistryProvider.register(groups);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,7 @@ export const componentsModule = angular
 | 
			
		|||
      'error',
 | 
			
		||||
      'horizontal',
 | 
			
		||||
      'isGroupVisible',
 | 
			
		||||
      'required',
 | 
			
		||||
    ])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,8 +8,10 @@ import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView';
 | 
			
		|||
import { ListView as EdgeStacksListView } from '@/react/edge/edge-stacks/ListView';
 | 
			
		||||
import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
 | 
			
		||||
 | 
			
		||||
import { templatesModule } from './templates';
 | 
			
		||||
 | 
			
		||||
export const viewsModule = angular
 | 
			
		||||
  .module('portainer.edge.react.views', [])
 | 
			
		||||
  .module('portainer.edge.react.views', [templatesModule])
 | 
			
		||||
  .component(
 | 
			
		||||
    'waitingRoomView',
 | 
			
		||||
    r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import angular from 'angular';
 | 
			
		||||
 | 
			
		||||
import { r2a } from '@/react-tools/react2angular';
 | 
			
		||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
 | 
			
		||||
import { withUIRouter } from '@/react-tools/withUIRouter';
 | 
			
		||||
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
 | 
			
		||||
 | 
			
		||||
export const templatesModule = angular
 | 
			
		||||
  .module('portainer.app.react.components.templates', [])
 | 
			
		||||
 | 
			
		||||
  .component(
 | 
			
		||||
    'edgeAppTemplatesView',
 | 
			
		||||
    r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
 | 
			
		||||
  ).name;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation';
 | 
			
		||||
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
 | 
			
		||||
 | 
			
		||||
class DockerComposeFormController {
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +36,7 @@ class DockerComposeFormController {
 | 
			
		|||
    return this.$async(async () => {
 | 
			
		||||
      this.formValues.StackFileContent = '';
 | 
			
		||||
      try {
 | 
			
		||||
        const fileContent = await this.EdgeTemplateService.edgeTemplate(template);
 | 
			
		||||
        const fileContent = await fetchFilePreview(template.id);
 | 
			
		||||
        this.formValues.StackFileContent = fileContent;
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        this.Notifications.error('Failure', err, 'Unable to retrieve Template');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import helm from '@/assets/ico/vendor/helm.svg?c';
 | 
			
		||||
 | 
			
		||||
import { BadgeIcon } from '@@/BadgeIcon';
 | 
			
		||||
 | 
			
		||||
export const HelmIcon = <BadgeIcon icon={helm} />;
 | 
			
		||||
| 
						 | 
				
			
			@ -3,7 +3,7 @@
 | 
			
		|||
  <div class="blocklist-item-box">
 | 
			
		||||
    <!-- helmchart-image -->
 | 
			
		||||
    <span class="shrink-0">
 | 
			
		||||
      <fallback-image src="$ctrl.model.icon" fallback-icon="'svg-helm'" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image>
 | 
			
		||||
      <fallback-image src="$ctrl.model.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image>
 | 
			
		||||
    </span>
 | 
			
		||||
    <!-- helmchart-details -->
 | 
			
		||||
    <div class="col-sm-12 helm-template-item-details">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import angular from 'angular';
 | 
			
		||||
import './helm-templates-list-item.css';
 | 
			
		||||
import { HelmIcon } from '../../HelmIcon';
 | 
			
		||||
 | 
			
		||||
angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
 | 
			
		||||
  templateUrl: './helm-templates-list-item.html',
 | 
			
		||||
| 
						 | 
				
			
			@ -10,4 +11,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
 | 
			
		|||
  transclude: {
 | 
			
		||||
    actions: '?templateItemActions',
 | 
			
		||||
  },
 | 
			
		||||
  controller() {
 | 
			
		||||
    this.fallbackIcon = HelmIcon;
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import _ from 'lodash-es';
 | 
			
		||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
 | 
			
		||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
 | 
			
		||||
 | 
			
		||||
import { HelmIcon } from './HelmIcon';
 | 
			
		||||
export default class HelmTemplatesController {
 | 
			
		||||
  /* @ngInject */
 | 
			
		||||
  constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ export default class HelmTemplatesController {
 | 
			
		|||
    this.KubernetesResourcePoolService = KubernetesResourcePoolService;
 | 
			
		||||
    this.Notifications = Notifications;
 | 
			
		||||
 | 
			
		||||
    this.fallbackIcon = HelmIcon;
 | 
			
		||||
 | 
			
		||||
    this.editorUpdate = this.editorUpdate.bind(this);
 | 
			
		||||
    this.uiCanExit = this.uiCanExit.bind(this);
 | 
			
		||||
    this.installHelmchart = this.installHelmchart.bind(this);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@
 | 
			
		|||
      <div class="flex">
 | 
			
		||||
        <div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
 | 
			
		||||
          <div class="vertical-center p-5">
 | 
			
		||||
            <fallback-image src="$ctrl.state.chart.icon" fallback-icon="'svg-helm'" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
 | 
			
		||||
            <fallback-image src="$ctrl.state.chart.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
 | 
			
		||||
            <div class="font-medium ml-4">
 | 
			
		||||
              <div class="toolBarTitle text-[24px] mb-2">
 | 
			
		||||
                {{ $ctrl.state.chart.name }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,6 +38,17 @@ export const ngModule = angular
 | 
			
		|||
      'isVariablesNamesFromParent',
 | 
			
		||||
    ])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'appTemplatesList',
 | 
			
		||||
    r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
 | 
			
		||||
      'onSelect',
 | 
			
		||||
      'templates',
 | 
			
		||||
      'selectedId',
 | 
			
		||||
      'disabledTypes',
 | 
			
		||||
      'fixedCategories',
 | 
			
		||||
      'hideDuplicate',
 | 
			
		||||
    ])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'customTemplatesList',
 | 
			
		||||
    r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [
 | 
			
		||||
| 
						 | 
				
			
			@ -54,15 +65,6 @@ export const ngModule = angular
 | 
			
		|||
  .component(
 | 
			
		||||
    'customTemplatesTypeSelector',
 | 
			
		||||
    r2a(TemplateTypeSelector, ['onChange', 'value'])
 | 
			
		||||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'appTemplatesList',
 | 
			
		||||
    r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
 | 
			
		||||
      'onSelect',
 | 
			
		||||
      'templates',
 | 
			
		||||
      'selectedId',
 | 
			
		||||
      'showSwarmStacks',
 | 
			
		||||
    ])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
withFormValidation(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -118,7 +118,7 @@ export const ngModule = angular
 | 
			
		|||
  )
 | 
			
		||||
  .component(
 | 
			
		||||
    'fallbackImage',
 | 
			
		||||
    r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
 | 
			
		||||
    r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'className'])
 | 
			
		||||
  )
 | 
			
		||||
  .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
 | 
			
		||||
  .component(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import { getTemplateVariables, intersectVariables } from '@/react/portainer/cust
 | 
			
		|||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
 | 
			
		||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
 | 
			
		||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
 | 
			
		||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation';
 | 
			
		||||
 | 
			
		||||
class CreateCustomTemplateViewController {
 | 
			
		||||
  /* @ngInject */
 | 
			
		||||
| 
						 | 
				
			
			@ -218,38 +219,43 @@ class CreateCustomTemplateViewController {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  async $onInit() {
 | 
			
		||||
    const applicationState = this.StateManager.getState();
 | 
			
		||||
    return this.$async(async () => {
 | 
			
		||||
      const applicationState = this.StateManager.getState();
 | 
			
		||||
 | 
			
		||||
    this.state.endpointMode = applicationState.endpoint.mode;
 | 
			
		||||
    let stackType = 0;
 | 
			
		||||
    if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
 | 
			
		||||
      this.isDockerStandalone = true;
 | 
			
		||||
      stackType = 2;
 | 
			
		||||
    } else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
 | 
			
		||||
      stackType = 1;
 | 
			
		||||
    }
 | 
			
		||||
    this.formValues.Type = stackType;
 | 
			
		||||
 | 
			
		||||
    const { fileContent, type } = this.$state.params;
 | 
			
		||||
 | 
			
		||||
    this.formValues.FileContent = fileContent;
 | 
			
		||||
    if (type) {
 | 
			
		||||
      this.formValues.Type = +type;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.state.loading = false;
 | 
			
		||||
 | 
			
		||||
    this.$window.onbeforeunload = () => {
 | 
			
		||||
      if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
 | 
			
		||||
        return '';
 | 
			
		||||
      this.state.endpointMode = applicationState.endpoint.mode;
 | 
			
		||||
      let stackType = 0;
 | 
			
		||||
      if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
 | 
			
		||||
        this.isDockerStandalone = true;
 | 
			
		||||
        stackType = 2;
 | 
			
		||||
      } else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
 | 
			
		||||
        stackType = 1;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
      this.formValues.Type = stackType;
 | 
			
		||||
 | 
			
		||||
      const { appTemplateId, type } = this.$state.params;
 | 
			
		||||
 | 
			
		||||
      if (type) {
 | 
			
		||||
        this.formValues.Type = +type;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (appTemplateId) {
 | 
			
		||||
        this.formValues.FileContent = await fetchFilePreview(appTemplateId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.state.loading = false;
 | 
			
		||||
 | 
			
		||||
      this.$window.onbeforeunload = () => {
 | 
			
		||||
        if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
 | 
			
		||||
          return '';
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  $onDestroy() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -270,9 +270,4 @@
 | 
			
		|||
  <!-- container-form -->
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<app-templates-list
 | 
			
		||||
  templates="templates"
 | 
			
		||||
  on-select="(selectTemplate)"
 | 
			
		||||
  selected-id="state.selectedTemplate.Id"
 | 
			
		||||
  show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
 | 
			
		||||
></app-templates-list>
 | 
			
		||||
<app-templates-list templates="templates" on-select="(selectTemplate)" selected-id="state.selectedTemplate.Id" disabled-types="disabledTypes"></app-templates-list>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import _ from 'lodash-es';
 | 
			
		||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
 | 
			
		||||
import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel';
 | 
			
		||||
 | 
			
		||||
angular.module('portainer.app').controller('TemplatesController', [
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +49,8 @@ angular.module('portainer.app').controller('TemplatesController', [
 | 
			
		|||
      actionInProgress: false,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    $scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack];
 | 
			
		||||
 | 
			
		||||
    $scope.formValues = {
 | 
			
		||||
      network: '',
 | 
			
		||||
      name: '',
 | 
			
		||||
| 
						 | 
				
			
			@ -282,6 +285,10 @@ angular.module('portainer.app').controller('TemplatesController', [
 | 
			
		|||
      var apiVersion = $scope.applicationState.endpoint.apiVersion;
 | 
			
		||||
      const endpointId = +$state.params.endpointId;
 | 
			
		||||
 | 
			
		||||
      const showSwarmStacks = endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25;
 | 
			
		||||
 | 
			
		||||
      $scope.disabledTypes = !showSwarmStacks ? [TemplateType.SwarmStack] : [];
 | 
			
		||||
 | 
			
		||||
      $q.all({
 | 
			
		||||
        templates: TemplateService.templates(endpointId),
 | 
			
		||||
        volumes: VolumeService.getVolumes(),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ export function BlocklistItem<T extends ElementType>({
 | 
			
		|||
      type="button"
 | 
			
		||||
      className={clsx(
 | 
			
		||||
        className,
 | 
			
		||||
        'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0',
 | 
			
		||||
        'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 text-left',
 | 
			
		||||
        {
 | 
			
		||||
          'blocklist-item--selected': isSelected,
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,23 +1,14 @@
 | 
			
		|||
import { useEffect, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
import { BadgeIcon, BadgeSize } from './BadgeIcon/BadgeIcon';
 | 
			
		||||
import { ReactNode, useEffect, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  // props for the image to load
 | 
			
		||||
  src?: string; // a link to an external image
 | 
			
		||||
  fallbackIcon: string;
 | 
			
		||||
  fallbackIcon: ReactNode;
 | 
			
		||||
  alt?: string;
 | 
			
		||||
  size?: BadgeSize;
 | 
			
		||||
  className?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FallbackImage({
 | 
			
		||||
  src,
 | 
			
		||||
  fallbackIcon,
 | 
			
		||||
  alt,
 | 
			
		||||
  size,
 | 
			
		||||
  className,
 | 
			
		||||
}: Props) {
 | 
			
		||||
export function FallbackImage({ src, fallbackIcon, alt, className }: Props) {
 | 
			
		||||
  const [error, setError] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -36,5 +27,5 @@ export function FallbackImage({
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  // fallback icon if there is an error loading the image
 | 
			
		||||
  return <BadgeIcon icon={fallbackIcon} size={size} />;
 | 
			
		||||
  return <>{fallbackIcon}</>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ import { Select as ReactSelect } from '@@/form-components/ReactSelect';
 | 
			
		|||
export interface Option<TValue> {
 | 
			
		||||
  value: TValue;
 | 
			
		||||
  label: string;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Options<TValue> = OptionsOrGroups<
 | 
			
		||||
| 
						 | 
				
			
			@ -99,6 +100,7 @@ export function SingleSelect<TValue = string>({
 | 
			
		|||
      options={options}
 | 
			
		||||
      value={selectedValue}
 | 
			
		||||
      onChange={(option) => onChange(option ? option.value : null)}
 | 
			
		||||
      isOptionDisabled={(option) => !!option.disabled}
 | 
			
		||||
      data-cy={dataCy}
 | 
			
		||||
      inputId={inputId}
 | 
			
		||||
      placeholder={placeholder}
 | 
			
		||||
| 
						 | 
				
			
			@ -155,6 +157,7 @@ export function MultiSelect<TValue = string>({
 | 
			
		|||
      isClearable={isClearable}
 | 
			
		||||
      getOptionLabel={(option) => option.label}
 | 
			
		||||
      getOptionValue={(option) => String(option.value)}
 | 
			
		||||
      isOptionDisabled={(option) => !!option.disabled}
 | 
			
		||||
      options={options}
 | 
			
		||||
      value={selectedOptions}
 | 
			
		||||
      closeMenuOnSelect={false}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,50 @@
 | 
			
		|||
import { FormikErrors } from 'formik';
 | 
			
		||||
import { SchemaOf, string } from 'yup';
 | 
			
		||||
 | 
			
		||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
 | 
			
		||||
 | 
			
		||||
import { FormControl } from '@@/form-components/FormControl';
 | 
			
		||||
import { Input } from '@@/form-components/Input';
 | 
			
		||||
 | 
			
		||||
import { EdgeStack } from '../types';
 | 
			
		||||
 | 
			
		||||
export function NameField({
 | 
			
		||||
  onChange,
 | 
			
		||||
  value,
 | 
			
		||||
  errors,
 | 
			
		||||
}: {
 | 
			
		||||
  onChange(value: string): void;
 | 
			
		||||
  value: string;
 | 
			
		||||
  errors?: FormikErrors<string>;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <FormControl inputId="name-input" label="Name" errors={errors} required>
 | 
			
		||||
      <Input
 | 
			
		||||
        id="name-input"
 | 
			
		||||
        onChange={(e) => onChange(e.target.value)}
 | 
			
		||||
        value={value}
 | 
			
		||||
        required
 | 
			
		||||
      />
 | 
			
		||||
    </FormControl>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function nameValidation(
 | 
			
		||||
  stacks: Array<EdgeStack>,
 | 
			
		||||
  isComposeStack: boolean | undefined
 | 
			
		||||
): SchemaOf<string> {
 | 
			
		||||
  let schema = string()
 | 
			
		||||
    .required('Name is required')
 | 
			
		||||
    .test('unique', 'Name should be unique', (value) =>
 | 
			
		||||
      stacks.every((s) => s.Name !== value)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  if (isComposeStack) {
 | 
			
		||||
    schema = schema.matches(
 | 
			
		||||
      new RegExp(STACK_NAME_VALIDATION_REGEX),
 | 
			
		||||
      "This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return schema;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import _ from 'lodash';
 | 
			
		|||
 | 
			
		||||
import { notifyError } from '@/portainer/services/notifications';
 | 
			
		||||
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
 | 
			
		||||
import { useCreateStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateStackFromFileContent';
 | 
			
		||||
import { useCreateEdgeStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent';
 | 
			
		||||
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
 | 
			
		||||
 | 
			
		||||
import { FormValues } from './types';
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ export function PrivateRegistryFieldsetWrapper({
 | 
			
		|||
  stackName: string;
 | 
			
		||||
  onFieldError: (message: string) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const dryRunMutation = useCreateStackFromFileContent();
 | 
			
		||||
  const dryRunMutation = useCreateEdgeStackFromFileContent();
 | 
			
		||||
 | 
			
		||||
  const registriesQuery = useRegistries();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,7 @@ interface Props {
 | 
			
		|||
  error?: string | string[];
 | 
			
		||||
  horizontal?: boolean;
 | 
			
		||||
  isGroupVisible?(group: EdgeGroup): boolean;
 | 
			
		||||
  required?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function EdgeGroupsSelector({
 | 
			
		||||
| 
						 | 
				
			
			@ -26,6 +27,7 @@ export function EdgeGroupsSelector({
 | 
			
		|||
  error,
 | 
			
		||||
  horizontal,
 | 
			
		||||
  isGroupVisible = () => true,
 | 
			
		||||
  required,
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const selector = (
 | 
			
		||||
    <InnerSelector
 | 
			
		||||
| 
						 | 
				
			
			@ -36,11 +38,11 @@ export function EdgeGroupsSelector({
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  return horizontal ? (
 | 
			
		||||
    <FormControl errors={error} label="Edge Groups">
 | 
			
		||||
    <FormControl errors={error} label="Edge Groups" required={required}>
 | 
			
		||||
      {selector}
 | 
			
		||||
    </FormControl>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <FormSection title="Edge Groups">
 | 
			
		||||
    <FormSection title={`Edge Groups${required ? ' *' : ''}`}>
 | 
			
		||||
      <div className="form-group">
 | 
			
		||||
        <div className="col-sm-12">{selector} </div>
 | 
			
		||||
        {error && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,21 @@
 | 
			
		|||
import { useMutation } from 'react-query';
 | 
			
		||||
import { useMutation, useQueryClient } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
import { withError } from '@/react-tools/react-query';
 | 
			
		||||
import { withError, withInvalidate } from '@/react-tools/react-query';
 | 
			
		||||
import { RegistryId } from '@/react/portainer/registries/types';
 | 
			
		||||
 | 
			
		||||
import { EdgeGroup } from '../../edge-groups/types';
 | 
			
		||||
import { DeploymentType, EdgeStack } from '../types';
 | 
			
		||||
 | 
			
		||||
import { buildUrl } from './buildUrl';
 | 
			
		||||
import { queryKeys } from './query-keys';
 | 
			
		||||
 | 
			
		||||
export function useCreateStackFromFileContent() {
 | 
			
		||||
  return useMutation(createStackFromFileContent, {
 | 
			
		||||
export function useCreateEdgeStackFromFileContent() {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  return useMutation(createEdgeStackFromFileContent, {
 | 
			
		||||
    ...withError('Failed creating Edge stack'),
 | 
			
		||||
    ...withInvalidate(queryClient, [queryKeys.base()]),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +30,7 @@ interface FileContentPayload {
 | 
			
		|||
  dryRun?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createStackFromFileContent({
 | 
			
		||||
export async function createEdgeStackFromFileContent({
 | 
			
		||||
  dryRun,
 | 
			
		||||
  ...payload
 | 
			
		||||
}: FileContentPayload) {
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,142 @@
 | 
			
		|||
import { useMutation, useQueryClient } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
import { withError, withInvalidate } from '@/react-tools/react-query';
 | 
			
		||||
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
 | 
			
		||||
import { Pair } from '@/react/portainer/settings/types';
 | 
			
		||||
import { RegistryId } from '@/react/portainer/registries/types';
 | 
			
		||||
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
 | 
			
		||||
 | 
			
		||||
import { DeploymentType, EdgeStack } from '../types';
 | 
			
		||||
import { EdgeGroup } from '../../edge-groups/types';
 | 
			
		||||
 | 
			
		||||
import { buildUrl } from './buildUrl';
 | 
			
		||||
import { queryKeys } from './query-keys';
 | 
			
		||||
 | 
			
		||||
export function useCreateEdgeStackFromGit() {
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  return useMutation(createEdgeStackFromGit, {
 | 
			
		||||
    ...withError('Failed creating Edge stack'),
 | 
			
		||||
    ...withInvalidate(queryClient, [queryKeys.base()]),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Represents the payload for creating an edge stack from a Git repository.
 | 
			
		||||
 */
 | 
			
		||||
interface GitPayload {
 | 
			
		||||
  /** Name of the stack. */
 | 
			
		||||
  name: string;
 | 
			
		||||
  /** URL of a Git repository hosting the Stack file. */
 | 
			
		||||
  repositoryURL: string;
 | 
			
		||||
  /** Reference name of a Git repository hosting the Stack file. */
 | 
			
		||||
  repositoryReferenceName?: string;
 | 
			
		||||
  /** Use basic authentication to clone the Git repository. */
 | 
			
		||||
  repositoryAuthentication?: boolean;
 | 
			
		||||
  /** Username used in basic authentication. Required when RepositoryAuthentication is true. */
 | 
			
		||||
  repositoryUsername?: string;
 | 
			
		||||
  /** Password used in basic authentication. Required when RepositoryAuthentication is true. */
 | 
			
		||||
  repositoryPassword?: string;
 | 
			
		||||
  /** GitCredentialID used to identify the bound git credential. */
 | 
			
		||||
  repositoryGitCredentialID?: GitCredential['id'];
 | 
			
		||||
  /** Path to the Stack file inside the Git repository. */
 | 
			
		||||
  filePathInRepository?: string;
 | 
			
		||||
  /** List of identifiers of EdgeGroups. */
 | 
			
		||||
  edgeGroups: Array<EdgeGroup['Id']>;
 | 
			
		||||
  /** Deployment type to deploy this stack. */
 | 
			
		||||
  deploymentType: DeploymentType;
 | 
			
		||||
  /** List of Registries to use for this stack. */
 | 
			
		||||
  registries?: RegistryId[];
 | 
			
		||||
  /** Uses the manifest's namespaces instead of the default one. */
 | 
			
		||||
  useManifestNamespaces?: boolean;
 | 
			
		||||
  /** Pre-pull image. */
 | 
			
		||||
  prePullImage?: boolean;
 | 
			
		||||
  /** Retry deploy. */
 | 
			
		||||
  retryDeploy?: boolean;
 | 
			
		||||
  /** TLSSkipVerify skips SSL verification when cloning the Git repository. */
 | 
			
		||||
  tLSSkipVerify?: boolean;
 | 
			
		||||
  /** Optional GitOps update configuration. */
 | 
			
		||||
  autoUpdate?: AutoUpdateModel;
 | 
			
		||||
  /** Whether the stack supports relative path volume. */
 | 
			
		||||
  supportRelativePath?: boolean;
 | 
			
		||||
  /** Local filesystem path. */
 | 
			
		||||
  filesystemPath?: string;
 | 
			
		||||
  /** Whether the edge stack supports per device configs. */
 | 
			
		||||
  supportPerDeviceConfigs?: boolean;
 | 
			
		||||
  /** Per device configs match type. */
 | 
			
		||||
  perDeviceConfigsMatchType?: 'file' | 'dir';
 | 
			
		||||
  /** Per device configs group match type. */
 | 
			
		||||
  perDeviceConfigsGroupMatchType?: 'file' | 'dir';
 | 
			
		||||
  /** Per device configs path. */
 | 
			
		||||
  perDeviceConfigsPath?: string;
 | 
			
		||||
  /** List of environment variables. */
 | 
			
		||||
  envVars?: Pair[];
 | 
			
		||||
  /** Configuration for stagger updates. */
 | 
			
		||||
  staggerConfig?: EdgeStaggerConfig;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * Represents the staggered updates configuration.
 | 
			
		||||
 */
 | 
			
		||||
interface EdgeStaggerConfig {
 | 
			
		||||
  /** Stagger option for updates. */
 | 
			
		||||
  staggerOption: EdgeStaggerOption;
 | 
			
		||||
  /** Stagger parallel option for updates. */
 | 
			
		||||
  staggerParallelOption: EdgeStaggerParallelOption;
 | 
			
		||||
  /** Device number for updates. */
 | 
			
		||||
  deviceNumber: number;
 | 
			
		||||
  /** Starting device number for updates. */
 | 
			
		||||
  deviceNumberStartFrom: number;
 | 
			
		||||
  /** Increment value for device numbers during updates. */
 | 
			
		||||
  deviceNumberIncrementBy: number;
 | 
			
		||||
  /** Timeout for updates (in minutes). */
 | 
			
		||||
  timeout: string;
 | 
			
		||||
  /** Update delay (in minutes). */
 | 
			
		||||
  updateDelay: string;
 | 
			
		||||
  /** Action to take in case of update failure. */
 | 
			
		||||
  updateFailureAction: EdgeUpdateFailureAction;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** EdgeStaggerOption represents an Edge stack stagger option */
 | 
			
		||||
enum EdgeStaggerOption {
 | 
			
		||||
  /** AllAtOnce represents a staggered deployment where all nodes are updated at once */
 | 
			
		||||
  AllAtOnce = 1,
 | 
			
		||||
  /** OneByOne represents a staggered deployment where nodes are updated with parallel setting  */
 | 
			
		||||
  Parallel,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** EdgeStaggerParallelOption represents an Edge stack stagger parallel option */
 | 
			
		||||
enum EdgeStaggerParallelOption {
 | 
			
		||||
  /** Fixed represents a staggered deployment where nodes are updated with a fixed number of nodes in parallel */
 | 
			
		||||
  Fixed = 1,
 | 
			
		||||
  /** Incremental represents a staggered deployment where nodes are updated with an incremental number of nodes in parallel */
 | 
			
		||||
  Incremental,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** EdgeUpdateFailureAction represents an Edge stack update failure action */
 | 
			
		||||
enum EdgeUpdateFailureAction {
 | 
			
		||||
  /** Continue represents that stagger update will continue regardless of whether the endpoint update status */
 | 
			
		||||
  Continue = 1,
 | 
			
		||||
  /** Pause represents that stagger update will pause when the endpoint update status is failed */
 | 
			
		||||
  Pause,
 | 
			
		||||
  /** Rollback represents that stagger update will rollback as long as one endpoint update status is failed */
 | 
			
		||||
  Rollback,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createEdgeStackFromGit({
 | 
			
		||||
  dryRun,
 | 
			
		||||
  ...payload
 | 
			
		||||
}: GitPayload & { dryRun?: boolean }) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.post<EdgeStack>(
 | 
			
		||||
      buildUrl(undefined, 'create/repository'),
 | 
			
		||||
      payload,
 | 
			
		||||
      {
 | 
			
		||||
        params: { dryrun: dryRun ? 'true' : 'false' },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    throw parseAxiosError(e as Error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
import { useParamState } from '@/react/hooks/useParamState';
 | 
			
		||||
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
 | 
			
		||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
 | 
			
		||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
 | 
			
		||||
 | 
			
		||||
import { PageHeader } from '@@/PageHeader';
 | 
			
		||||
 | 
			
		||||
import { DeployFormWidget } from './DeployForm';
 | 
			
		||||
 | 
			
		||||
export function AppTemplatesView() {
 | 
			
		||||
  const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
 | 
			
		||||
    'template',
 | 
			
		||||
    (param) => (param ? parseInt(param, 10) : 0)
 | 
			
		||||
  );
 | 
			
		||||
  const templatesQuery = useAppTemplates();
 | 
			
		||||
  const selectedTemplate = selectedTemplateId
 | 
			
		||||
    ? templatesQuery.data?.find(
 | 
			
		||||
        (template) => template.Id === selectedTemplateId
 | 
			
		||||
      )
 | 
			
		||||
    : undefined;
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PageHeader title="Application templates list" breadcrumbs="Templates" />
 | 
			
		||||
      {selectedTemplate && (
 | 
			
		||||
        <DeployFormWidget
 | 
			
		||||
          template={selectedTemplate}
 | 
			
		||||
          unselect={() => setSelectedTemplateId()}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <AppTemplatesList
 | 
			
		||||
        templates={templatesQuery.data}
 | 
			
		||||
        selectedId={selectedTemplateId}
 | 
			
		||||
        onSelect={(template) => setSelectedTemplateId(template.Id)}
 | 
			
		||||
        disabledTypes={[TemplateType.Container]}
 | 
			
		||||
        fixedCategories={['edge']}
 | 
			
		||||
        hideDuplicate
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,190 @@
 | 
			
		|||
import { Rocket } from 'lucide-react';
 | 
			
		||||
import { Form, Formik } from 'formik';
 | 
			
		||||
import { array, lazy, number, object, string } from 'yup';
 | 
			
		||||
import { useRouter } from '@uirouter/react';
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
 | 
			
		||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
 | 
			
		||||
import { EnvironmentType } from '@/react/portainer/environments/types';
 | 
			
		||||
import { notifySuccess } from '@/portainer/services/notifications';
 | 
			
		||||
 | 
			
		||||
import { Widget } from '@@/Widget';
 | 
			
		||||
import { FallbackImage } from '@@/FallbackImage';
 | 
			
		||||
import { Icon } from '@@/Icon';
 | 
			
		||||
import { FormActions } from '@@/form-components/FormActions';
 | 
			
		||||
import { Button } from '@@/buttons';
 | 
			
		||||
 | 
			
		||||
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
 | 
			
		||||
import {
 | 
			
		||||
  NameField,
 | 
			
		||||
  nameValidation,
 | 
			
		||||
} from '../../edge-stacks/CreateView/NameField';
 | 
			
		||||
import { EdgeGroup } from '../../edge-groups/types';
 | 
			
		||||
import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
 | 
			
		||||
import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
 | 
			
		||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
 | 
			
		||||
import { useCreateEdgeStackFromGit } from '../../edge-stacks/queries/useCreateEdgeStackFromGit';
 | 
			
		||||
 | 
			
		||||
import { EnvVarsFieldset } from './EnvVarsFieldset';
 | 
			
		||||
 | 
			
		||||
export function DeployFormWidget({
 | 
			
		||||
  template,
 | 
			
		||||
  unselect,
 | 
			
		||||
}: {
 | 
			
		||||
  template: TemplateViewModel;
 | 
			
		||||
  unselect: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="row">
 | 
			
		||||
      <div className="col-sm-12">
 | 
			
		||||
        <Widget>
 | 
			
		||||
          <Widget.Title
 | 
			
		||||
            icon={
 | 
			
		||||
              <FallbackImage
 | 
			
		||||
                src={template.Logo}
 | 
			
		||||
                fallbackIcon={<Icon icon={Rocket} />}
 | 
			
		||||
              />
 | 
			
		||||
            }
 | 
			
		||||
            title={template.Title}
 | 
			
		||||
          />
 | 
			
		||||
          <Widget.Body>
 | 
			
		||||
            <DeployForm template={template} unselect={unselect} />
 | 
			
		||||
          </Widget.Body>
 | 
			
		||||
        </Widget>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface FormValues {
 | 
			
		||||
  name: string;
 | 
			
		||||
  edgeGroupIds: Array<EdgeGroup['Id']>;
 | 
			
		||||
  envVars: Record<string, string>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DeployForm({
 | 
			
		||||
  template,
 | 
			
		||||
  unselect,
 | 
			
		||||
}: {
 | 
			
		||||
  template: TemplateViewModel;
 | 
			
		||||
  unselect: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const mutation = useCreateEdgeStackFromGit();
 | 
			
		||||
  const edgeStacksQuery = useEdgeStacks();
 | 
			
		||||
  const edgeGroupsQuery = useEdgeGroups({
 | 
			
		||||
    select: (groups) =>
 | 
			
		||||
      Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const initialValues: FormValues = {
 | 
			
		||||
    edgeGroupIds: [],
 | 
			
		||||
    name: template.Name || '',
 | 
			
		||||
    envVars:
 | 
			
		||||
      Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
 | 
			
		||||
      {},
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Formik
 | 
			
		||||
      initialValues={initialValues}
 | 
			
		||||
      onSubmit={handleSubmit}
 | 
			
		||||
      validationSchema={() =>
 | 
			
		||||
        validation(edgeStacksQuery.data, edgeGroupsQuery.data)
 | 
			
		||||
      }
 | 
			
		||||
      validateOnMount
 | 
			
		||||
    >
 | 
			
		||||
      {({ values, errors, setFieldValue, isValid }) => (
 | 
			
		||||
        <Form className="form-horizontal">
 | 
			
		||||
          <NameField
 | 
			
		||||
            value={values.name}
 | 
			
		||||
            onChange={(v) => setFieldValue('name', v)}
 | 
			
		||||
            errors={errors.name}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <EdgeGroupsSelector
 | 
			
		||||
            horizontal
 | 
			
		||||
            value={values.edgeGroupIds}
 | 
			
		||||
            error={errors.edgeGroupIds}
 | 
			
		||||
            onChange={(value) => setFieldValue('edgeGroupIds', value)}
 | 
			
		||||
            required
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <EnvVarsFieldset
 | 
			
		||||
            value={values.envVars}
 | 
			
		||||
            options={template.Env}
 | 
			
		||||
            errors={errors.envVars}
 | 
			
		||||
            onChange={(values) => setFieldValue('envVars', values)}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <FormActions
 | 
			
		||||
            isLoading={mutation.isLoading}
 | 
			
		||||
            isValid={isValid}
 | 
			
		||||
            loadingText="Deployment in progress..."
 | 
			
		||||
            submitLabel="Deploy the stack"
 | 
			
		||||
          >
 | 
			
		||||
            <Button type="reset" onClick={() => unselect()} color="default">
 | 
			
		||||
              Hide
 | 
			
		||||
            </Button>
 | 
			
		||||
          </FormActions>
 | 
			
		||||
        </Form>
 | 
			
		||||
      )}
 | 
			
		||||
    </Formik>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  function handleSubmit(values: FormValues) {
 | 
			
		||||
    return mutation.mutate(
 | 
			
		||||
      {
 | 
			
		||||
        name: values.name,
 | 
			
		||||
        edgeGroups: values.edgeGroupIds,
 | 
			
		||||
        deploymentType: DeploymentType.Compose,
 | 
			
		||||
        repositoryURL: template.Repository.url,
 | 
			
		||||
        filePathInRepository: template.Repository.stackfile,
 | 
			
		||||
        envVars: Object.entries(values.envVars).map(([name, value]) => ({
 | 
			
		||||
          name,
 | 
			
		||||
          value,
 | 
			
		||||
        })),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        onSuccess() {
 | 
			
		||||
          notifySuccess('Success', 'Edge Stack created');
 | 
			
		||||
          router.stateService.go('edge.stacks');
 | 
			
		||||
        },
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function validation(
 | 
			
		||||
  stacks: EdgeStack[],
 | 
			
		||||
  edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
 | 
			
		||||
) {
 | 
			
		||||
  return lazy((values: FormValues) => {
 | 
			
		||||
    const types = getTypes(values.edgeGroupIds);
 | 
			
		||||
 | 
			
		||||
    return object({
 | 
			
		||||
      name: nameValidation(
 | 
			
		||||
        stacks,
 | 
			
		||||
        types?.includes(EnvironmentType.EdgeAgentOnDocker)
 | 
			
		||||
      ),
 | 
			
		||||
      edgeGroupIds: array(number().required().default(0))
 | 
			
		||||
        .min(1, 'At least one group is required')
 | 
			
		||||
        .test(
 | 
			
		||||
          'same-type',
 | 
			
		||||
          'Groups should be of the same type',
 | 
			
		||||
          (value) => _.uniq(getTypes(value)).length === 1
 | 
			
		||||
        ),
 | 
			
		||||
      envVars: array()
 | 
			
		||||
        .transform((_, orig) => Object.values(orig))
 | 
			
		||||
        .of(string().required('Required')),
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  function getTypes(value: number[] | undefined) {
 | 
			
		||||
    return value?.flatMap((g) => edgeGroupsType[g]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,76 @@
 | 
			
		|||
import { FormikErrors } from 'formik';
 | 
			
		||||
 | 
			
		||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
 | 
			
		||||
 | 
			
		||||
import { FormControl } from '@@/form-components/FormControl';
 | 
			
		||||
import { Input, Select } from '@@/form-components/Input';
 | 
			
		||||
 | 
			
		||||
type Value = Record<string, string>;
 | 
			
		||||
 | 
			
		||||
export function EnvVarsFieldset({
 | 
			
		||||
  onChange,
 | 
			
		||||
  options,
 | 
			
		||||
  value,
 | 
			
		||||
  errors,
 | 
			
		||||
}: {
 | 
			
		||||
  options: Array<TemplateEnv>;
 | 
			
		||||
  onChange: (value: Value) => void;
 | 
			
		||||
  value: Value;
 | 
			
		||||
  errors?: FormikErrors<Value>;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {options.map((env, index) => (
 | 
			
		||||
        <Item
 | 
			
		||||
          key={env.name}
 | 
			
		||||
          option={env}
 | 
			
		||||
          value={value[env.name]}
 | 
			
		||||
          onChange={(value) => handleChange(env.name, value)}
 | 
			
		||||
          errors={errors?.[index]}
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  function handleChange(name: string, envValue: string) {
 | 
			
		||||
    onChange({ ...value, [name]: envValue });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Item({
 | 
			
		||||
  onChange,
 | 
			
		||||
  option,
 | 
			
		||||
  value,
 | 
			
		||||
  errors,
 | 
			
		||||
}: {
 | 
			
		||||
  option: TemplateEnv;
 | 
			
		||||
  value: string;
 | 
			
		||||
  onChange: (value: string) => void;
 | 
			
		||||
  errors?: FormikErrors<string>;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <FormControl
 | 
			
		||||
      label={option.label || option.name}
 | 
			
		||||
      required={!option.preset}
 | 
			
		||||
      errors={errors}
 | 
			
		||||
    >
 | 
			
		||||
      {option.select ? (
 | 
			
		||||
        <Select
 | 
			
		||||
          value={value}
 | 
			
		||||
          onChange={(e) => onChange(e.target.value)}
 | 
			
		||||
          options={option.select.map((o) => ({
 | 
			
		||||
            label: o.text,
 | 
			
		||||
            value: o.value,
 | 
			
		||||
          }))}
 | 
			
		||||
          disabled={option.preset}
 | 
			
		||||
        />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Input
 | 
			
		||||
          value={value}
 | 
			
		||||
          onChange={(e) => onChange(e.target.value)}
 | 
			
		||||
          disabled={option.preset}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </FormControl>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export { AppTemplatesView } from './AppTemplatesView';
 | 
			
		||||
| 
						 | 
				
			
			@ -2,7 +2,8 @@ import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
 | 
			
		|||
 | 
			
		||||
export function useParamState<T>(
 | 
			
		||||
  param: string,
 | 
			
		||||
  parseParam: (param: string | undefined) => T | undefined
 | 
			
		||||
  parseParam: (param: string | undefined) => T | undefined = (param) =>
 | 
			
		||||
    param as unknown as T
 | 
			
		||||
) {
 | 
			
		||||
  const {
 | 
			
		||||
    params: { [param]: paramValue },
 | 
			
		||||
| 
						 | 
				
			
			@ -12,7 +13,7 @@ export function useParamState<T>(
 | 
			
		|||
 | 
			
		||||
  return [
 | 
			
		||||
    state,
 | 
			
		||||
    (value: T | undefined) => {
 | 
			
		||||
    (value?: T) => {
 | 
			
		||||
      router.stateService.go('.', { [param]: value });
 | 
			
		||||
    },
 | 
			
		||||
  ] as const;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,12 +23,11 @@ export function TemplatesUrlSection() {
 | 
			
		|||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <FormControl label="URL" inputId="templates_url" required errors={error}>
 | 
			
		||||
      <FormControl label="URL" inputId="templates_url" errors={error}>
 | 
			
		||||
        <Field
 | 
			
		||||
          as={Input}
 | 
			
		||||
          id="templates_url"
 | 
			
		||||
          placeholder="https://myserver.mydomain/templates.json"
 | 
			
		||||
          required
 | 
			
		||||
          data-cy="settings-templateUrl"
 | 
			
		||||
          name={name}
 | 
			
		||||
        />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import { Values } from './types';
 | 
			
		|||
export function validation(): SchemaOf<Values> {
 | 
			
		||||
  return object({
 | 
			
		||||
    edgeAgentCheckinInterval: number().required(),
 | 
			
		||||
    enableTelemetry: bool().required(),
 | 
			
		||||
    enableTelemetry: bool().default(false),
 | 
			
		||||
    loginBannerEnabled: boolean().default(false),
 | 
			
		||||
    loginBanner: string()
 | 
			
		||||
      .default('')
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +30,11 @@ export function validation(): SchemaOf<Values> {
 | 
			
		|||
      }),
 | 
			
		||||
    snapshotInterval: string().required('Snapshot interval is required'),
 | 
			
		||||
    templatesUrl: string()
 | 
			
		||||
      .required('Templates URL is required')
 | 
			
		||||
      .test('valid-url', 'Must be a valid URL', (value) => isValidUrl(value)),
 | 
			
		||||
      .default('')
 | 
			
		||||
      .test(
 | 
			
		||||
        'valid-url',
 | 
			
		||||
        'Must be a valid URL',
 | 
			
		||||
        (value) => !value || isValidUrl(value)
 | 
			
		||||
      ),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
import { Edit } from 'lucide-react';
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
import { useState } from 'react';
 | 
			
		||||
import { useRouter } from '@uirouter/react';
 | 
			
		||||
 | 
			
		||||
import { DatatableHeader } from '@@/datatables/DatatableHeader';
 | 
			
		||||
import { Table } from '@@/datatables';
 | 
			
		||||
| 
						 | 
				
			
			@ -11,39 +10,41 @@ import { DatatableFooter } from '@@/datatables/DatatableFooter';
 | 
			
		|||
 | 
			
		||||
import { AppTemplatesListItem } from './AppTemplatesListItem';
 | 
			
		||||
import { TemplateViewModel } from './view-model';
 | 
			
		||||
import { ListState } from './types';
 | 
			
		||||
import { ListState, TemplateType } from './types';
 | 
			
		||||
import { useSortAndFilterTemplates } from './useSortAndFilter';
 | 
			
		||||
import { Filters } from './Filters';
 | 
			
		||||
import { useFetchTemplateInfoMutation } from './useFetchTemplateInfoMutation';
 | 
			
		||||
 | 
			
		||||
const tableKey = 'app-templates-list';
 | 
			
		||||
const store = createPersistedStore<ListState>(tableKey, undefined, (set) => ({
 | 
			
		||||
  category: null,
 | 
			
		||||
  setCategory: (category: ListState['category']) => set({ category }),
 | 
			
		||||
  type: null,
 | 
			
		||||
  setType: (type: ListState['type']) => set({ type }),
 | 
			
		||||
  types: [],
 | 
			
		||||
  setTypes: (types: ListState['types']) => set({ types }),
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
export function AppTemplatesList({
 | 
			
		||||
  templates,
 | 
			
		||||
  onSelect,
 | 
			
		||||
  selectedId,
 | 
			
		||||
  showSwarmStacks,
 | 
			
		||||
  disabledTypes,
 | 
			
		||||
  fixedCategories,
 | 
			
		||||
  hideDuplicate,
 | 
			
		||||
}: {
 | 
			
		||||
  templates?: TemplateViewModel[];
 | 
			
		||||
  onSelect: (template: TemplateViewModel) => void;
 | 
			
		||||
  selectedId?: TemplateViewModel['Id'];
 | 
			
		||||
  showSwarmStacks?: boolean;
 | 
			
		||||
  disabledTypes?: Array<TemplateType>;
 | 
			
		||||
  fixedCategories?: Array<string>;
 | 
			
		||||
  hideDuplicate?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const fetchTemplateInfoMutation = useFetchTemplateInfoMutation();
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const [page, setPage] = useState(0);
 | 
			
		||||
 | 
			
		||||
  const listState = useTableState(store, tableKey);
 | 
			
		||||
  const filteredTemplates = useSortAndFilterTemplates(
 | 
			
		||||
    templates || [],
 | 
			
		||||
    listState,
 | 
			
		||||
    showSwarmStacks
 | 
			
		||||
    disabledTypes,
 | 
			
		||||
    fixedCategories
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const pagedTemplates =
 | 
			
		||||
| 
						 | 
				
			
			@ -59,8 +60,10 @@ export function AppTemplatesList({
 | 
			
		|||
        description={
 | 
			
		||||
          <Filters
 | 
			
		||||
            listState={listState}
 | 
			
		||||
            templates={templates || []}
 | 
			
		||||
            templates={filteredTemplates || []}
 | 
			
		||||
            onChange={() => setPage(0)}
 | 
			
		||||
            disabledTypes={disabledTypes}
 | 
			
		||||
            fixedCategories={fixedCategories}
 | 
			
		||||
          />
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
| 
						 | 
				
			
			@ -71,8 +74,8 @@ export function AppTemplatesList({
 | 
			
		|||
            key={template.Id}
 | 
			
		||||
            template={template}
 | 
			
		||||
            onSelect={onSelect}
 | 
			
		||||
            onDuplicate={onDuplicate}
 | 
			
		||||
            isSelected={selectedId === template.Id}
 | 
			
		||||
            hideDuplicate={hideDuplicate}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
        {!templates && <div className="text-muted text-center">Loading...</div>}
 | 
			
		||||
| 
						 | 
				
			
			@ -96,15 +99,4 @@ export function AppTemplatesList({
 | 
			
		|||
    listState.setSearch(search);
 | 
			
		||||
    setPage(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function onDuplicate(template: TemplateViewModel) {
 | 
			
		||||
    fetchTemplateInfoMutation.mutate(template, {
 | 
			
		||||
      onSuccess({ fileContent, type }) {
 | 
			
		||||
        router.stateService.go('.custom.new', {
 | 
			
		||||
          fileContent,
 | 
			
		||||
          type,
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,7 @@
 | 
			
		|||
import { StackType } from '@/react/common/stacks/types';
 | 
			
		||||
 | 
			
		||||
import { Button } from '@@/buttons';
 | 
			
		||||
import { Link } from '@@/Link';
 | 
			
		||||
 | 
			
		||||
import { TemplateItem } from '../components/TemplateItem';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -8,14 +11,16 @@ import { TemplateType } from './types';
 | 
			
		|||
export function AppTemplatesListItem({
 | 
			
		||||
  template,
 | 
			
		||||
  onSelect,
 | 
			
		||||
  onDuplicate,
 | 
			
		||||
  isSelected,
 | 
			
		||||
  hideDuplicate = false,
 | 
			
		||||
}: {
 | 
			
		||||
  template: TemplateViewModel;
 | 
			
		||||
  onSelect: (template: TemplateViewModel) => void;
 | 
			
		||||
  onDuplicate: (template: TemplateViewModel) => void;
 | 
			
		||||
  isSelected: boolean;
 | 
			
		||||
  hideDuplicate?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TemplateItem
 | 
			
		||||
      template={template}
 | 
			
		||||
| 
						 | 
				
			
			@ -25,21 +30,39 @@ export function AppTemplatesListItem({
 | 
			
		|||
      onSelect={() => onSelect(template)}
 | 
			
		||||
      isSelected={isSelected}
 | 
			
		||||
      renderActions={
 | 
			
		||||
        template.Type === TemplateType.SwarmStack ||
 | 
			
		||||
        (template.Type === TemplateType.ComposeStack && (
 | 
			
		||||
        !hideDuplicate &&
 | 
			
		||||
        duplicateCustomTemplateType && (
 | 
			
		||||
          <div className="mr-5 mt-3">
 | 
			
		||||
            <Button
 | 
			
		||||
              as={Link}
 | 
			
		||||
              size="xsmall"
 | 
			
		||||
              onClick={(e) => {
 | 
			
		||||
                e.stopPropagation();
 | 
			
		||||
                onDuplicate(template);
 | 
			
		||||
              }}
 | 
			
		||||
              props={{
 | 
			
		||||
                to: '.custom.new',
 | 
			
		||||
                params: {
 | 
			
		||||
                  appTemplateId: template.Id,
 | 
			
		||||
                  type: duplicateCustomTemplateType,
 | 
			
		||||
                },
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              Copy as Custom
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        ))
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCustomTemplateType(type: TemplateType): StackType | null {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case TemplateType.SwarmStack:
 | 
			
		||||
      return StackType.DockerSwarm;
 | 
			
		||||
    case TemplateType.ComposeStack:
 | 
			
		||||
      return StackType.DockerCompose;
 | 
			
		||||
    default:
 | 
			
		||||
      return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,58 +1,79 @@
 | 
			
		|||
import _ from 'lodash';
 | 
			
		||||
 | 
			
		||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
 | 
			
		||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
 | 
			
		||||
 | 
			
		||||
import { ListState, TemplateType } from './types';
 | 
			
		||||
import { TemplateViewModel } from './view-model';
 | 
			
		||||
import { TemplateListSort } from './TemplateListSort';
 | 
			
		||||
 | 
			
		||||
const orderByFields = ['Title', 'Categories', 'Description'] as const;
 | 
			
		||||
const typeFilters = [
 | 
			
		||||
const typeFilters: ReadonlyArray<Option<TemplateType>> = [
 | 
			
		||||
  { label: 'Container', value: TemplateType.Container },
 | 
			
		||||
  { label: 'Stack', value: TemplateType.SwarmStack },
 | 
			
		||||
  { label: 'Swarm Stack', value: TemplateType.SwarmStack },
 | 
			
		||||
  { label: 'Compose Stack', value: TemplateType.ComposeStack },
 | 
			
		||||
] as const;
 | 
			
		||||
 | 
			
		||||
export function Filters({
 | 
			
		||||
  templates,
 | 
			
		||||
  listState,
 | 
			
		||||
  onChange,
 | 
			
		||||
  disabledTypes = [],
 | 
			
		||||
  fixedCategories = [],
 | 
			
		||||
}: {
 | 
			
		||||
  templates: TemplateViewModel[];
 | 
			
		||||
  listState: ListState & { search: string };
 | 
			
		||||
  onChange(): void;
 | 
			
		||||
  disabledTypes?: Array<TemplateType>;
 | 
			
		||||
  fixedCategories?: Array<string>;
 | 
			
		||||
}) {
 | 
			
		||||
  const categories = _.sortBy(
 | 
			
		||||
    _.uniq(templates?.flatMap((template) => template.Categories))
 | 
			
		||||
  ).map((category) => ({ label: category, value: category }));
 | 
			
		||||
  )
 | 
			
		||||
    .filter((category) => !fixedCategories.includes(category))
 | 
			
		||||
    .map((category) => ({ label: category, value: category }));
 | 
			
		||||
 | 
			
		||||
  const templatesTypes = _.uniq(
 | 
			
		||||
    templates?.flatMap((template) => template.Type)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const typeFiltersEnabled = typeFilters.filter(
 | 
			
		||||
    (type) =>
 | 
			
		||||
      !disabledTypes.includes(type.value) && templatesTypes.includes(type.value)
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex gap-4 w-full">
 | 
			
		||||
      <div className="w-1/4">
 | 
			
		||||
        <PortainerSelect
 | 
			
		||||
          options={categories}
 | 
			
		||||
          onChange={(category) => {
 | 
			
		||||
            listState.setCategory(category);
 | 
			
		||||
            onChange();
 | 
			
		||||
          }}
 | 
			
		||||
          placeholder="Category"
 | 
			
		||||
          value={listState.category}
 | 
			
		||||
          bindToBody
 | 
			
		||||
          isClearable
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="w-1/4">
 | 
			
		||||
        <PortainerSelect
 | 
			
		||||
          options={typeFilters}
 | 
			
		||||
          onChange={(type) => {
 | 
			
		||||
            listState.setType(type);
 | 
			
		||||
            onChange();
 | 
			
		||||
          }}
 | 
			
		||||
          placeholder="Type"
 | 
			
		||||
          value={listState.type}
 | 
			
		||||
          bindToBody
 | 
			
		||||
          isClearable
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      {categories.length > 0 && (
 | 
			
		||||
        <div className="w-1/4">
 | 
			
		||||
          <PortainerSelect
 | 
			
		||||
            options={categories}
 | 
			
		||||
            onChange={(category) => {
 | 
			
		||||
              listState.setCategory(category);
 | 
			
		||||
              onChange();
 | 
			
		||||
            }}
 | 
			
		||||
            placeholder="Category"
 | 
			
		||||
            value={listState.category}
 | 
			
		||||
            bindToBody
 | 
			
		||||
            isClearable
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      {typeFiltersEnabled.length > 1 && (
 | 
			
		||||
        <div className="w-1/4">
 | 
			
		||||
          <PortainerSelect<TemplateType>
 | 
			
		||||
            isMulti
 | 
			
		||||
            options={typeFiltersEnabled}
 | 
			
		||||
            onChange={(types) => {
 | 
			
		||||
              listState.setTypes(types);
 | 
			
		||||
              onChange();
 | 
			
		||||
            }}
 | 
			
		||||
            placeholder="Type"
 | 
			
		||||
            value={listState.types}
 | 
			
		||||
            bindToBody
 | 
			
		||||
            isClearable
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      <div className="w-1/4 ml-auto">
 | 
			
		||||
        <TemplateListSort
 | 
			
		||||
          onChange={(value) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
import { AppTemplate } from '../types';
 | 
			
		||||
 | 
			
		||||
export function buildUrl({
 | 
			
		||||
  id,
 | 
			
		||||
  action,
 | 
			
		||||
}: { id?: AppTemplate['id']; action?: string } = {}) {
 | 
			
		||||
  let baseUrl = '/templates';
 | 
			
		||||
 | 
			
		||||
  if (id) {
 | 
			
		||||
    baseUrl += `/${id}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (action) {
 | 
			
		||||
    baseUrl += `/${action}`;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return baseUrl;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,54 @@
 | 
			
		|||
import { useQuery } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
 | 
			
		||||
import { DockerHubViewModel } from '@/portainer/models/dockerhub';
 | 
			
		||||
import { Registry } from '@/react/portainer/registries/types/registry';
 | 
			
		||||
 | 
			
		||||
import { AppTemplate } from '../types';
 | 
			
		||||
import { TemplateViewModel } from '../view-model';
 | 
			
		||||
 | 
			
		||||
import { buildUrl } from './build-url';
 | 
			
		||||
 | 
			
		||||
export function useAppTemplates() {
 | 
			
		||||
  const registriesQuery = useRegistries();
 | 
			
		||||
 | 
			
		||||
  return useQuery(
 | 
			
		||||
    'templates',
 | 
			
		||||
    () => getTemplatesWithRegistry(registriesQuery.data),
 | 
			
		||||
    {
 | 
			
		||||
      enabled: !!registriesQuery.data,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getTemplatesWithRegistry(
 | 
			
		||||
  registries: Array<Registry> | undefined
 | 
			
		||||
) {
 | 
			
		||||
  if (!registries) {
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { templates, version } = await getTemplates();
 | 
			
		||||
  return templates.map((item) => {
 | 
			
		||||
    const template = new TemplateViewModel(item, version);
 | 
			
		||||
    const registryURL = item.registry;
 | 
			
		||||
    const registry = registryURL
 | 
			
		||||
      ? registries.find((reg) => reg.URL === registryURL)
 | 
			
		||||
      : new DockerHubViewModel();
 | 
			
		||||
    template.RegistryModel.Registry = registry || new DockerHubViewModel();
 | 
			
		||||
    return template;
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getTemplates() {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.get<{
 | 
			
		||||
      version: string;
 | 
			
		||||
      templates: Array<AppTemplate>;
 | 
			
		||||
    }>(buildUrl());
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    throw parseAxiosError(err);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
 | 
			
		||||
import { AppTemplate } from '../types';
 | 
			
		||||
 | 
			
		||||
import { buildUrl } from './build-url';
 | 
			
		||||
 | 
			
		||||
export async function fetchFilePreview(id: AppTemplate['id']) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.post<{ FileContent: string }>(
 | 
			
		||||
      buildUrl({ id, action: 'file' })
 | 
			
		||||
    );
 | 
			
		||||
    return data.FileContent;
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    throw parseAxiosError(err);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,15 +5,14 @@ import { Pair } from '../../settings/types';
 | 
			
		|||
export interface ListState extends BasicTableSettings {
 | 
			
		||||
  category: string | null;
 | 
			
		||||
  setCategory: (category: string | null) => void;
 | 
			
		||||
  type: TemplateType | null;
 | 
			
		||||
  setType: (type: TemplateType | null) => void;
 | 
			
		||||
  types: ReadonlyArray<TemplateType>;
 | 
			
		||||
  setTypes: (value: ReadonlyArray<TemplateType>) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum TemplateType {
 | 
			
		||||
  Container = 1,
 | 
			
		||||
  SwarmStack = 2,
 | 
			
		||||
  ComposeStack = 3,
 | 
			
		||||
  EdgeStack = 4,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +20,12 @@ export enum TemplateType {
 | 
			
		|||
 */
 | 
			
		||||
export interface AppTemplate {
 | 
			
		||||
  /**
 | 
			
		||||
   * Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack).
 | 
			
		||||
   * Unique identifier of the template.
 | 
			
		||||
   */
 | 
			
		||||
  id: number;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack)
 | 
			
		||||
   * @example 1
 | 
			
		||||
   */
 | 
			
		||||
  type: TemplateType;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,52 +0,0 @@
 | 
			
		|||
import { useMutation } from 'react-query';
 | 
			
		||||
 | 
			
		||||
import { StackType } from '@/react/common/stacks/types';
 | 
			
		||||
import { mutationOptions, withGlobalError } from '@/react-tools/react-query';
 | 
			
		||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
 | 
			
		||||
 | 
			
		||||
import { TemplateType } from './types';
 | 
			
		||||
import { TemplateViewModel } from './view-model';
 | 
			
		||||
 | 
			
		||||
export function useFetchTemplateInfoMutation() {
 | 
			
		||||
  return useMutation(
 | 
			
		||||
    getTemplateInfo,
 | 
			
		||||
    mutationOptions(withGlobalError('Unable to fetch template info'))
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getTemplateInfo(template: TemplateViewModel) {
 | 
			
		||||
  const fileContent = await fetchFilePreview({
 | 
			
		||||
    url: template.Repository.url,
 | 
			
		||||
    file: template.Repository.stackfile,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const type = getCustomTemplateType(template.Type);
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    type,
 | 
			
		||||
    fileContent,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getCustomTemplateType(type: TemplateType): StackType {
 | 
			
		||||
  switch (type) {
 | 
			
		||||
    case TemplateType.SwarmStack:
 | 
			
		||||
      return StackType.DockerSwarm;
 | 
			
		||||
    case TemplateType.ComposeStack:
 | 
			
		||||
      return StackType.DockerCompose;
 | 
			
		||||
    default:
 | 
			
		||||
      throw new Error(`Unknown supported template type: ${type}`);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function fetchFilePreview({ url, file }: { url: string; file: string }) {
 | 
			
		||||
  try {
 | 
			
		||||
    const { data } = await axios.post<{ FileContent: string }>(
 | 
			
		||||
      '/templates/file',
 | 
			
		||||
      { repositoryUrl: url, composeFilePathInRepository: file }
 | 
			
		||||
    );
 | 
			
		||||
    return data.FileContent;
 | 
			
		||||
  } catch (err) {
 | 
			
		||||
    throw parseAxiosError(err);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,22 +1,26 @@
 | 
			
		|||
import { useCallback, useMemo } from 'react';
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
 | 
			
		||||
import { TemplateViewModel } from './view-model';
 | 
			
		||||
import { ListState, TemplateType } from './types';
 | 
			
		||||
import { ListState } from './types';
 | 
			
		||||
 | 
			
		||||
export function useSortAndFilterTemplates(
 | 
			
		||||
  templates: Array<TemplateViewModel>,
 | 
			
		||||
  listState: ListState & { search: string },
 | 
			
		||||
  showSwarmStacks?: boolean
 | 
			
		||||
  disabledTypes: Array<TemplateViewModel['Type']> = [],
 | 
			
		||||
  fixedCategories: Array<string> = []
 | 
			
		||||
) {
 | 
			
		||||
  const filterByCategory = useCallback(
 | 
			
		||||
    (item: TemplateViewModel) => {
 | 
			
		||||
      if (!listState.category) {
 | 
			
		||||
      if (!listState.category && !fixedCategories.length) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return item.Categories.includes(listState.category);
 | 
			
		||||
      return _.compact([...fixedCategories, listState.category]).every(
 | 
			
		||||
        (category) => item.Categories.includes(category)
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [listState.category]
 | 
			
		||||
    [fixedCategories, listState.category]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const filterBySearch = useCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -37,29 +41,20 @@ export function useSortAndFilterTemplates(
 | 
			
		|||
 | 
			
		||||
  const filterByTemplateType = useCallback(
 | 
			
		||||
    (item: TemplateViewModel) => {
 | 
			
		||||
      switch (item.Type) {
 | 
			
		||||
        case TemplateType.Container:
 | 
			
		||||
          return (
 | 
			
		||||
            listState.type === TemplateType.Container || listState.type === null
 | 
			
		||||
          );
 | 
			
		||||
        case TemplateType.SwarmStack:
 | 
			
		||||
          return (
 | 
			
		||||
            showSwarmStacks &&
 | 
			
		||||
            (listState.type === TemplateType.SwarmStack ||
 | 
			
		||||
              listState.type === null)
 | 
			
		||||
          );
 | 
			
		||||
        case TemplateType.ComposeStack:
 | 
			
		||||
          return (
 | 
			
		||||
            listState.type === TemplateType.SwarmStack ||
 | 
			
		||||
            listState.type === null
 | 
			
		||||
          );
 | 
			
		||||
        case TemplateType.EdgeStack:
 | 
			
		||||
          return listState.type === TemplateType.EdgeStack;
 | 
			
		||||
        default:
 | 
			
		||||
          return false;
 | 
			
		||||
      if (listState.types.length === 0 && disabledTypes.length === 0) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (listState.types.length === 0) {
 | 
			
		||||
        return !disabledTypes.includes(item.Type);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        listState.types.includes(item.Type) &&
 | 
			
		||||
        !disabledTypes.includes(item.Type)
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [listState.type, showSwarmStacks]
 | 
			
		||||
    [disabledTypes, listState.types]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const sort = useCallback(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ import {
 | 
			
		|||
} from './types';
 | 
			
		||||
 | 
			
		||||
export class TemplateViewModel {
 | 
			
		||||
  Id!: string;
 | 
			
		||||
  Id!: number;
 | 
			
		||||
 | 
			
		||||
  Title!: string;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -65,46 +65,56 @@ export class TemplateViewModel {
 | 
			
		|||
    protocol: string;
 | 
			
		||||
  }[];
 | 
			
		||||
 | 
			
		||||
  constructor(data: AppTemplate, version: string) {
 | 
			
		||||
  constructor(template: AppTemplate, version: string) {
 | 
			
		||||
    switch (version) {
 | 
			
		||||
      case '2':
 | 
			
		||||
        this.setTemplatesV2(data);
 | 
			
		||||
        setTemplatesV2.call(this, template);
 | 
			
		||||
        break;
 | 
			
		||||
      case '3':
 | 
			
		||||
        setTemplatesV3.call(this, template);
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error('Unsupported template version');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  setTemplatesV2(template: AppTemplate) {
 | 
			
		||||
    this.Id = _.uniqueId();
 | 
			
		||||
    this.Title = template.title;
 | 
			
		||||
    this.Type = template.type;
 | 
			
		||||
    this.Description = template.description;
 | 
			
		||||
    this.AdministratorOnly = template.administrator_only;
 | 
			
		||||
    this.Name = template.name;
 | 
			
		||||
    this.Note = template.note;
 | 
			
		||||
    this.Categories = template.categories ? template.categories : [];
 | 
			
		||||
    this.Platform = getPlatform(template.platform);
 | 
			
		||||
    this.Logo = template.logo;
 | 
			
		||||
    this.Repository = template.repository;
 | 
			
		||||
    this.Hostname = template.hostname;
 | 
			
		||||
    this.RegistryModel = new PorImageRegistryModel();
 | 
			
		||||
    this.RegistryModel.Image = template.image;
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    this.RegistryModel.Registry.URL = template.registry || '';
 | 
			
		||||
    this.Command = template.command ? template.command : '';
 | 
			
		||||
    this.Network = template.network ? template.network : '';
 | 
			
		||||
    this.Privileged = template.privileged ? template.privileged : false;
 | 
			
		||||
    this.Interactive = template.interactive ? template.interactive : false;
 | 
			
		||||
    this.RestartPolicy = template.restart_policy
 | 
			
		||||
      ? template.restart_policy
 | 
			
		||||
      : 'always';
 | 
			
		||||
    this.Labels = template.labels ? template.labels : [];
 | 
			
		||||
    this.Env = templateEnv(template);
 | 
			
		||||
    this.Volumes = templateVolumes(template);
 | 
			
		||||
    this.Ports = templatePorts(template);
 | 
			
		||||
  }
 | 
			
		||||
function setTemplatesV3(this: TemplateViewModel, template: AppTemplate) {
 | 
			
		||||
  setTemplatesV2.call(this, template);
 | 
			
		||||
  this.Id = template.id;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let templateV2ID = 0;
 | 
			
		||||
 | 
			
		||||
function setTemplatesV2(this: TemplateViewModel, template: AppTemplate) {
 | 
			
		||||
  this.Id = templateV2ID++;
 | 
			
		||||
  this.Title = template.title;
 | 
			
		||||
  this.Type = template.type;
 | 
			
		||||
  this.Description = template.description;
 | 
			
		||||
  this.AdministratorOnly = template.administrator_only;
 | 
			
		||||
  this.Name = template.name;
 | 
			
		||||
  this.Note = template.note;
 | 
			
		||||
  this.Categories = template.categories ? template.categories : [];
 | 
			
		||||
  this.Platform = getPlatform(template.platform);
 | 
			
		||||
  this.Logo = template.logo;
 | 
			
		||||
  this.Repository = template.repository;
 | 
			
		||||
  this.Hostname = template.hostname;
 | 
			
		||||
  this.RegistryModel = new PorImageRegistryModel();
 | 
			
		||||
  this.RegistryModel.Image = template.image;
 | 
			
		||||
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 | 
			
		||||
  // @ts-ignore
 | 
			
		||||
  this.RegistryModel.Registry.URL = template.registry || '';
 | 
			
		||||
  this.Command = template.command ? template.command : '';
 | 
			
		||||
  this.Network = template.network ? template.network : '';
 | 
			
		||||
  this.Privileged = template.privileged ? template.privileged : false;
 | 
			
		||||
  this.Interactive = template.interactive ? template.interactive : false;
 | 
			
		||||
  this.RestartPolicy = template.restart_policy
 | 
			
		||||
    ? template.restart_policy
 | 
			
		||||
    : 'always';
 | 
			
		||||
  this.Labels = template.labels ? template.labels : [];
 | 
			
		||||
  this.Env = templateEnv(template);
 | 
			
		||||
  this.Volumes = templateVolumes(template);
 | 
			
		||||
  this.Ports = templatePorts(template);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function templatePorts(data: AppTemplate) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { ReactNode } from 'react';
 | 
			
		||||
import { Rocket } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
import LinuxIcon from '@/assets/ico/linux.svg?c';
 | 
			
		||||
import MicrosoftIcon from '@/assets/ico/vendor/microsoft.svg?c';
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +8,7 @@ import KubernetesIcon from '@/assets/ico/vendor/kubernetes.svg?c';
 | 
			
		|||
import { Icon } from '@@/Icon';
 | 
			
		||||
import { FallbackImage } from '@@/FallbackImage';
 | 
			
		||||
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
 | 
			
		||||
import { BadgeIcon } from '@@/BadgeIcon';
 | 
			
		||||
 | 
			
		||||
import { Platform } from '../../custom-templates/types';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -38,9 +40,8 @@ export function TemplateItem({
 | 
			
		|||
        <div className="vertical-center min-w-[56px] justify-center">
 | 
			
		||||
          <FallbackImage
 | 
			
		||||
            src={template.Logo}
 | 
			
		||||
            fallbackIcon="rocket"
 | 
			
		||||
            fallbackIcon={<BadgeIcon icon={Rocket} size="3xl" />}
 | 
			
		||||
            className="blocklist-item-logo"
 | 
			
		||||
            size="3xl"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="col-sm-12 flex justify-between flex-wrap">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,11 @@
 | 
			
		|||
import { Box, Clock, LayoutGrid, Layers, Puzzle } from 'lucide-react';
 | 
			
		||||
import { Box, Clock, LayoutGrid, Layers, Puzzle, Edit } from 'lucide-react';
 | 
			
		||||
 | 
			
		||||
import { isBE } from '../portainer/feature-flags/feature-flags.service';
 | 
			
		||||
import { useSettings } from '../portainer/settings/queries';
 | 
			
		||||
 | 
			
		||||
import { SidebarItem } from './SidebarItem';
 | 
			
		||||
import { SidebarSection } from './SidebarSection';
 | 
			
		||||
import { SidebarParent } from './SidebarItem/SidebarParent';
 | 
			
		||||
 | 
			
		||||
export function EdgeComputeSidebar() {
 | 
			
		||||
  // this sidebar is rendered only for admins, so we can safely assume that settingsQuery will succeed
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +53,26 @@ export function EdgeComputeSidebar() {
 | 
			
		|||
          data-cy="portainerSidebar-edgeDevicesWaitingRoom"
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      <SidebarParent
 | 
			
		||||
        icon={Edit}
 | 
			
		||||
        label="Templates"
 | 
			
		||||
        to="edge.templates"
 | 
			
		||||
        data-cy="edgeSidebar-templates"
 | 
			
		||||
      >
 | 
			
		||||
        <SidebarItem
 | 
			
		||||
          label="Application"
 | 
			
		||||
          to="edge.templates"
 | 
			
		||||
          ignorePaths={['edge.templates.custom']}
 | 
			
		||||
          isSubMenu
 | 
			
		||||
          data-cy="edgeSidebar-appTemplates"
 | 
			
		||||
        />
 | 
			
		||||
        {/* <SidebarItem
 | 
			
		||||
          label="Custom"
 | 
			
		||||
          to="edge.templates.custom"
 | 
			
		||||
          isSubMenu
 | 
			
		||||
          data-cy="edgeSidebar-customTemplates"
 | 
			
		||||
        /> */}
 | 
			
		||||
      </SidebarParent>
 | 
			
		||||
    </SidebarSection>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue