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 }) => ( +
+ setFieldValue('name', v)} + errors={errors.name} + /> + + setFieldValue('edgeGroupIds', value)} + required + /> + + setFieldValue('envVars', values)} + /> + + + + + + )} +
+ ); + + 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 ? ( + onChange(e.target.value)} + disabled={option.preset} + /> + )} + + ); +} 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> = [ { 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; + fixedCategories?: Array; }) { 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 (
-
- { - listState.setCategory(category); - onChange(); - }} - placeholder="Category" - value={listState.category} - bindToBody - isClearable - /> -
-
- { - listState.setType(type); - onChange(); - }} - placeholder="Type" - value={listState.type} - bindToBody - isClearable - /> -
+ {categories.length > 0 && ( +
+ { + listState.setCategory(category); + onChange(); + }} + placeholder="Category" + value={listState.category} + bindToBody + isClearable + /> +
+ )} + {typeFiltersEnabled.length > 1 && ( +
+ + isMulti + options={typeFiltersEnabled} + onChange={(types) => { + listState.setTypes(types); + onChange(); + }} + placeholder="Type" + value={listState.types} + bindToBody + isClearable + /> +
+ )}
{ diff --git a/app/react/portainer/templates/app-templates/queries/build-url.ts b/app/react/portainer/templates/app-templates/queries/build-url.ts new file mode 100644 index 000000000..e32b50b8e --- /dev/null +++ b/app/react/portainer/templates/app-templates/queries/build-url.ts @@ -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; +} diff --git a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts new file mode 100644 index 000000000..0ceac608b --- /dev/null +++ b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts @@ -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 | 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; + }>(buildUrl()); + return data; + } catch (err) { + throw parseAxiosError(err); + } +} diff --git a/app/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation.ts b/app/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation.ts new file mode 100644 index 000000000..1237cc346 --- /dev/null +++ b/app/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation.ts @@ -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); + } +} diff --git a/app/react/portainer/templates/app-templates/types.ts b/app/react/portainer/templates/app-templates/types.ts index ee755fa8a..c51702940 100644 --- a/app/react/portainer/templates/app-templates/types.ts +++ b/app/react/portainer/templates/app-templates/types.ts @@ -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; + setTypes: (value: ReadonlyArray) => 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; diff --git a/app/react/portainer/templates/app-templates/useFetchTemplateInfoMutation.ts b/app/react/portainer/templates/app-templates/useFetchTemplateInfoMutation.ts deleted file mode 100644 index 7811112bf..000000000 --- a/app/react/portainer/templates/app-templates/useFetchTemplateInfoMutation.ts +++ /dev/null @@ -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); - } -} diff --git a/app/react/portainer/templates/app-templates/useSortAndFilter.tsx b/app/react/portainer/templates/app-templates/useSortAndFilter.tsx index 5cb29e15f..01a02330d 100644 --- a/app/react/portainer/templates/app-templates/useSortAndFilter.tsx +++ b/app/react/portainer/templates/app-templates/useSortAndFilter.tsx @@ -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, listState: ListState & { search: string }, - showSwarmStacks?: boolean + disabledTypes: Array = [], + fixedCategories: Array = [] ) { 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( diff --git a/app/react/portainer/templates/app-templates/view-model.ts b/app/react/portainer/templates/app-templates/view-model.ts index f787509e5..ea0a042cb 100644 --- a/app/react/portainer/templates/app-templates/view-model.ts +++ b/app/react/portainer/templates/app-templates/view-model.ts @@ -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) { diff --git a/app/react/portainer/templates/components/TemplateItem.tsx b/app/react/portainer/templates/components/TemplateItem.tsx index 02b99314b..d836c7165 100644 --- a/app/react/portainer/templates/components/TemplateItem.tsx +++ b/app/react/portainer/templates/components/TemplateItem.tsx @@ -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({
} className="blocklist-item-logo" - size="3xl" />
diff --git a/app/react/sidebar/EdgeComputeSidebar.tsx b/app/react/sidebar/EdgeComputeSidebar.tsx index d9924986f..9d8f3ef7f 100644 --- a/app/react/sidebar/EdgeComputeSidebar.tsx +++ b/app/react/sidebar/EdgeComputeSidebar.tsx @@ -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" /> )} + + + {/* */} + ); }