diff --git a/.eslintrc.yml b/.eslintrc.yml
index bd801a518..df3e7bd1d 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -23,7 +23,7 @@ parserOptions:
modules: true
rules:
- no-console: error
+ no-console: warn
no-alert: error
no-control-regex: 'off'
no-empty: warn
diff --git a/api/datastore/init.go b/api/datastore/init.go
index bc8161394..963a3ebc3 100644
--- a/api/datastore/init.go
+++ b/api/datastore/init.go
@@ -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,
diff --git a/api/datastore/migrator/migrate_dbversion100.go b/api/datastore/migrator/migrate_dbversion100.go
index 2cdd259ef..14896fd48 100644
--- a/api/datastore/migrator/migrate_dbversion100.go
+++ b/api/datastore/migrator/migrate_dbversion100.go
@@ -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 {
diff --git a/api/datastore/migrator/migrate_dbversion110.go b/api/datastore/migrator/migrate_dbversion110.go
new file mode 100644
index 000000000..88f0a4f19
--- /dev/null
+++ b/api/datastore/migrator/migrate_dbversion110.go
@@ -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)
+}
diff --git a/api/datastore/migrator/migrate_dbversion24.go b/api/datastore/migrator/migrate_dbversion24.go
index 724aecbb6..afca34579 100644
--- a/api/datastore/migrator/migrate_dbversion24.go
+++ b/api/datastore/migrator/migrate_dbversion24.go
@@ -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
diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go
index e5229f5c6..1e0f7253c 100644
--- a/api/datastore/migrator/migrator.go
+++ b/api/datastore/migrator/migrator.go
@@ -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.
}
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 6f1aa6e5b..ccff02ba6 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -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\"}"
}
}
\ No newline at end of file
diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go
index 2acdcc98c..de7b5199d 100644
--- a/api/http/handler/edgestacks/edgestack_create_git.go
+++ b/api/http/handler/edgestacks/edgestack_create_git.go
@@ -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
diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go
index 91fa9cf6c..4a988e557 100644
--- a/api/http/handler/edgetemplates/edgetemplate_list.go
+++ b/api/http/handler/edgetemplates/edgetemplate_list.go
@@ -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)
}
}
diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go
index a6ee14e1e..e604e8abe 100644
--- a/api/http/handler/templates/handler.go
+++ b/api/http/handler/templates/handler.go
@@ -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
}
diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go
index e56ccc305..33687a4bf 100644
--- a/api/http/handler/templates/template_file.go
+++ b/api/http/handler/templates/template_file.go
@@ -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)
}
diff --git a/api/http/handler/templates/template_file_old.go b/api/http/handler/templates/template_file_old.go
new file mode 100644
index 000000000..5c6d2e1bd
--- /dev/null
+++ b/api/http/handler/templates/template_file_old.go
@@ -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)})
+
+}
diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go
index 8ea591296..472cb618e 100644
--- a/api/http/handler/templates/template_list.go
+++ b/api/http/handler/templates/template_list.go
@@ -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)
}
diff --git a/api/http/handler/templates/utils_fetch_templates.go b/api/http/handler/templates/utils_fetch_templates.go
new file mode 100644
index 000000000..6feb9edeb
--- /dev/null
+++ b/api/http/handler/templates/utils_fetch_templates.go
@@ -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
+
+}
diff --git a/api/portainer.go b/api/portainer.go
index 347fd168d..23fe238e2 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -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 (
diff --git a/app/docker/__module.js b/app/docker/__module.js
index f85a986f7..ea2c5cb8e 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -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 = {
diff --git a/app/edge/__module.js b/app/edge/__module.js
index 3154469d3..1b0393e50 100644
--- a/app/edge/__module.js
+++ b/app/edge/__module.js
@@ -142,6 +142,16 @@ angular
});
}
+ $stateRegistryProvider.register({
+ name: 'edge.templates',
+ url: '/templates?template',
+ views: {
+ 'content@': {
+ component: 'edgeAppTemplatesView',
+ },
+ },
+ });
+
$stateRegistryProvider.register(edge);
$stateRegistryProvider.register(groups);
diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts
index 4f8564d2e..e2c67c309 100644
--- a/app/edge/react/components/index.ts
+++ b/app/edge/react/components/index.ts
@@ -28,6 +28,7 @@ export const componentsModule = angular
'error',
'horizontal',
'isGroupVisible',
+ 'required',
])
)
.component(
diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts
index 28a8bf1b4..179d1f981 100644
--- a/app/edge/react/views/index.ts
+++ b/app/edge/react/views/index.ts
@@ -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))), [])
diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts
new file mode 100644
index 000000000..d19233b35
--- /dev/null
+++ b/app/edge/react/views/templates.ts
@@ -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;
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
index da48c052a..68e9f444f 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js
@@ -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');
diff --git a/app/kubernetes/components/helm/helm-templates/HelmIcon.tsx b/app/kubernetes/components/helm/helm-templates/HelmIcon.tsx
new file mode 100644
index 000000000..f904a3a56
--- /dev/null
+++ b/app/kubernetes/components/helm/helm-templates/HelmIcon.tsx
@@ -0,0 +1,5 @@
+import helm from '@/assets/ico/vendor/helm.svg?c';
+
+import { BadgeIcon } from '@@/BadgeIcon';
+
+export const HelmIcon = ;
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
index 781adeba6..17cf83b81 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
@@ -3,7 +3,7 @@
-
+
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
index ca69d37e6..adde64a03 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
@@ -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;
+ },
});
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
index ef2e0b219..706eaf230 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
@@ -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);
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html
index 296896fd1..b785aafee 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates.html
@@ -5,7 +5,7 @@
-
+
{{ $ctrl.state.chart.name }}
diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts
index 333cfe768..9823bad0f 100644
--- a/app/portainer/react/components/custom-templates/index.ts
+++ b/app/portainer/react/components/custom-templates/index.ts
@@ -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(
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index 285f57131..b80f456c4 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -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(
diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js
index 068491039..3dc557db4 100644
--- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js
+++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js
@@ -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() {
diff --git a/app/portainer/views/templates/templates.html b/app/portainer/views/templates/templates.html
index 69ea724d8..955a4a805 100644
--- a/app/portainer/views/templates/templates.html
+++ b/app/portainer/views/templates/templates.html
@@ -270,9 +270,4 @@
-
+
diff --git a/app/portainer/views/templates/templatesController.js b/app/portainer/views/templates/templatesController.js
index 5e86e34d7..28d0b2d09 100644
--- a/app/portainer/views/templates/templatesController.js
+++ b/app/portainer/views/templates/templatesController.js
@@ -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(),
diff --git a/app/react/components/Blocklist/BlocklistItem.tsx b/app/react/components/Blocklist/BlocklistItem.tsx
index d3f2a4e26..a69b41149 100644
--- a/app/react/components/Blocklist/BlocklistItem.tsx
+++ b/app/react/components/Blocklist/BlocklistItem.tsx
@@ -23,7 +23,7 @@ export function BlocklistItem
({
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,
}
diff --git a/app/react/components/FallbackImage.tsx b/app/react/components/FallbackImage.tsx
index e251b29dd..ee6956f24 100644
--- a/app/react/components/FallbackImage.tsx
+++ b/app/react/components/FallbackImage.tsx
@@ -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 ;
+ return <>{fallbackIcon}>;
}
diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx
index 9dac97f8a..156cc33da 100644
--- a/app/react/components/form-components/PortainerSelect.tsx
+++ b/app/react/components/form-components/PortainerSelect.tsx
@@ -12,6 +12,7 @@ import { Select as ReactSelect } from '@@/form-components/ReactSelect';
export interface Option {
value: TValue;
label: string;
+ disabled?: boolean;
}
type Options = OptionsOrGroups<
@@ -99,6 +100,7 @@ export function SingleSelect({
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({
isClearable={isClearable}
getOptionLabel={(option) => option.label}
getOptionValue={(option) => String(option.value)}
+ isOptionDisabled={(option) => !!option.disabled}
options={options}
value={selectedOptions}
closeMenuOnSelect={false}
diff --git a/app/react/edge/edge-stacks/CreateView/NameField.tsx b/app/react/edge/edge-stacks/CreateView/NameField.tsx
new file mode 100644
index 000000000..27e9a9239
--- /dev/null
+++ b/app/react/edge/edge-stacks/CreateView/NameField.tsx
@@ -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;
+}) {
+ return (
+
+ onChange(e.target.value)}
+ value={value}
+ required
+ />
+
+ );
+}
+
+export function nameValidation(
+ stacks: Array,
+ isComposeStack: boolean | undefined
+): SchemaOf {
+ 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;
+}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx
index 3eb58cadc..e04bb6869 100644
--- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx
@@ -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();
diff --git a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx
index a8fcacccd..0e01b9514 100644
--- a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx
+++ b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx
@@ -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 = (
+
{selector}
) : (
-
+
{selector}
{error && (
diff --git a/app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts
similarity index 67%
rename from app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts
rename to app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts
index adcff0432..ab743b550 100644
--- a/app/react/edge/edge-stacks/queries/useCreateStackFromFileContent.ts
+++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts
@@ -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) {
diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts
new file mode 100644
index 000000000..913b0231b
--- /dev/null
+++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts
@@ -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
;
+ /** 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(
+ buildUrl(undefined, 'create/repository'),
+ payload,
+ {
+ params: { dryrun: dryRun ? 'true' : 'false' },
+ }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error);
+ }
+}
diff --git a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
new file mode 100644
index 000000000..0f8b35f27
--- /dev/null
+++ b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx
@@ -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(
+ 'template',
+ (param) => (param ? parseInt(param, 10) : 0)
+ );
+ const templatesQuery = useAppTemplates();
+ const selectedTemplate = selectedTemplateId
+ ? templatesQuery.data?.find(
+ (template) => template.Id === selectedTemplateId
+ )
+ : undefined;
+ return (
+ <>
+
+ {selectedTemplate && (
+ setSelectedTemplateId()}
+ />
+ )}
+
+ setSelectedTemplateId(template.Id)}
+ disabledTypes={[TemplateType.Container]}
+ fixedCategories={['edge']}
+ hideDuplicate
+ />
+ >
+ );
+}
diff --git a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx
new file mode 100644
index 000000000..e19895519
--- /dev/null
+++ b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx
@@ -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 (
+
+
+
+ }
+ />
+ }
+ title={template.Title}
+ />
+
+
+
+
+
+
+ );
+}
+
+interface FormValues {
+ name: string;
+ edgeGroupIds: Array;
+ envVars: Record;
+}
+
+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 (
+
+ validation(edgeStacksQuery.data, edgeGroupsQuery.data)
+ }
+ validateOnMount
+ >
+ {({ values, errors, setFieldValue, isValid }) => (
+
+ )}
+
+ );
+
+ 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>
+) {
+ 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]);
+ }
+}
diff --git a/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx b/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx
new file mode 100644
index 000000000..917bdc7ef
--- /dev/null
+++ b/app/react/edge/templates/AppTemplatesView/EnvVarsFieldset.tsx
@@ -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;
+
+export function EnvVarsFieldset({
+ onChange,
+ options,
+ value,
+ errors,
+}: {
+ options: Array;
+ onChange: (value: Value) => void;
+ value: Value;
+ errors?: FormikErrors;
+}) {
+ return (
+ <>
+ {options.map((env, index) => (
+ - 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;
+}) {
+ return (
+
+ {option.select ? (
+
+ );
+}
diff --git a/app/react/edge/templates/AppTemplatesView/index.ts b/app/react/edge/templates/AppTemplatesView/index.ts
new file mode 100644
index 000000000..d1b36c57b
--- /dev/null
+++ b/app/react/edge/templates/AppTemplatesView/index.ts
@@ -0,0 +1 @@
+export { AppTemplatesView } from './AppTemplatesView';
diff --git a/app/react/hooks/useParamState.ts b/app/react/hooks/useParamState.ts
index 96813d0cf..8125d6c99 100644
--- a/app/react/hooks/useParamState.ts
+++ b/app/react/hooks/useParamState.ts
@@ -2,7 +2,8 @@ import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
export function useParamState(
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(
return [
state,
- (value: T | undefined) => {
+ (value?: T) => {
router.stateService.go('.', { [param]: value });
},
] as const;
diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx
index 6dc77da12..beee1fdf5 100644
--- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx
+++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx
@@ -23,12 +23,11 @@ export function TemplatesUrlSection() {
-
+
diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts
index 0c9225980..3014d1499 100644
--- a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts
+++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts
@@ -7,7 +7,7 @@ import { Values } from './types';
export function validation(): SchemaOf {
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 {
}),
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)
+ ),
});
}
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
index 62642b045..f1881b29f 100644
--- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
+++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
@@ -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(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;
+ fixedCategories?: Array;
+ 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={
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 && Loading...
}
@@ -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,
- });
- },
- });
- }
}
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
index 84d895538..8e150ff9a 100644
--- a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
+++ b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx
@@ -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 (
onSelect(template)}
isSelected={isSelected}
renderActions={
- template.Type === TemplateType.SwarmStack ||
- (template.Type === TemplateType.ComposeStack && (
+ !hideDuplicate &&
+ duplicateCustomTemplateType && (
- ))
+ )
}
/>
);
}
+
+function getCustomTemplateType(type: TemplateType): StackType | null {
+ switch (type) {
+ case TemplateType.SwarmStack:
+ return StackType.DockerSwarm;
+ case TemplateType.ComposeStack:
+ return StackType.DockerCompose;
+ default:
+ return null;
+ }
+}
diff --git a/app/react/portainer/templates/app-templates/Filters.tsx b/app/react/portainer/templates/app-templates/Filters.tsx
index c8dbb50d9..d113cafee 100644
--- a/app/react/portainer/templates/app-templates/Filters.tsx
+++ b/app/react/portainer/templates/app-templates/Filters.tsx
@@ -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