feat(edge/templates): introduce edge app templates [EE-6209] (#10480)

pull/10631/head
Chaim Lev-Ari 2023-11-14 14:54:44 +02:00 committed by GitHub
parent 95d96e1164
commit e1e90c9c1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1142 additions and 365 deletions

View File

@ -23,7 +23,7 @@ parserOptions:
modules: true modules: true
rules: rules:
no-console: error no-console: warn
no-alert: error no-alert: error
no-control-regex: 'off' no-control-regex: 'off'
no-empty: warn no-empty: warn

View File

@ -53,7 +53,7 @@ func (store *Store) checkOrCreateDefaultSettings() error {
}, },
SnapshotInterval: portainer.DefaultSnapshotInterval, SnapshotInterval: portainer.DefaultSnapshotInterval,
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
TemplatesURL: portainer.DefaultTemplatesURL, TemplatesURL: "",
HelmRepositoryURL: portainer.DefaultHelmRepositoryURL, HelmRepositoryURL: portainer.DefaultHelmRepositoryURL,
UserSessionTimeout: portainer.DefaultUserSessionTimeout, UserSessionTimeout: portainer.DefaultUserSessionTimeout,
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,

View File

@ -10,8 +10,8 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func (m *Migrator) migrateDockerDesktopExtentionSetting() error { func (m *Migrator) migrateDockerDesktopExtensionSetting() error {
log.Info().Msg("updating docker desktop extention flag in settings") log.Info().Msg("updating docker desktop extension flag in settings")
isDDExtension := false isDDExtension := false
if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok { if _, ok := os.LookupEnv("DOCKER_EXTENSION"); ok {

View File

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

View File

@ -14,8 +14,10 @@ func (m *Migrator) updateSettingsToDB25() error {
return err 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 == "" { if legacySettings.TemplatesURL == "" {
legacySettings.TemplatesURL = portainer.DefaultTemplatesURL legacySettings.TemplatesURL = version2URL
} }
legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout legacySettings.UserSessionTimeout = portainer.DefaultUserSessionTimeout

View File

@ -225,10 +225,14 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.18", m.migrateDBVersionToDB90) m.addMigrations("2.18", m.migrateDBVersionToDB90)
m.addMigrations("2.19", m.addMigrations("2.19",
m.convertSeedToPrivateKeyForDB100, m.convertSeedToPrivateKeyForDB100,
m.migrateDockerDesktopExtentionSetting, m.migrateDockerDesktopExtensionSetting,
m.updateEdgeStackStatusForDB100, m.updateEdgeStackStatusForDB100,
) )
m.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110,
)
// Add new migrations below... // Add new migrations below...
// One function per migration, each versions migration funcs in the same file. // One function per migration, each versions migration funcs in the same file.
} }

View File

@ -645,7 +645,7 @@
}, },
"ShowKomposeBuildOption": false, "ShowKomposeBuildOption": false,
"SnapshotInterval": "5m", "SnapshotInterval": "5m",
"TemplatesURL": "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json", "TemplatesURL": "",
"TrustOnFirstConnect": false, "TrustOnFirstConnect": false,
"UserSessionTimeout": "8h", "UserSessionTimeout": "8h",
"fdoConfiguration": { "fdoConfiguration": {
@ -936,6 +936,6 @@
} }
], ],
"version": { "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\"}"
} }
} }

View File

@ -31,7 +31,7 @@ type edgeStackFromGitRepositoryPayload struct {
// Path to the Stack file inside the Git repository // Path to the Stack file inside the Git repository
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
// List of identifiers of EdgeGroups // List of identifiers of EdgeGroups
EdgeGroups []portainer.EdgeGroupID `example:"1"` EdgeGroups []portainer.EdgeGroupID `example:"1" validate:"required"`
// Deployment type to deploy this stack // Deployment type to deploy this stack
// Valid values are: 0 - 'compose', 1 - 'kubernetes' // Valid values are: 0 - 'compose', 1 - 'kubernetes'
// compose is enabled only for docker environments // compose is enabled only for docker environments
@ -85,7 +85,6 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @produce json // @produce json
// @param method query string true "Creation Method" Enums(file,string,repository)
// @param body body edgeStackFromGitRepositoryPayload true "stack config" // @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" // @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 // @success 200 {object} portainer.EdgeStack

View File

@ -2,6 +2,7 @@ package edgetemplates
import ( import (
"net/http" "net/http"
"slices"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/client" "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) 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) filteredTemplates := make([]portainer.Template, 0)
for _, template := range templateFile.Templates { 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) filteredTemplates = append(filteredTemplates, template)
} }
} }

View File

@ -26,8 +26,10 @@ func NewHandler(bouncer security.BouncerService) *Handler {
} }
h.Handle("/templates", 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", h.Handle("/templates/file",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost) bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
return h return h
} }

View File

@ -1,72 +1,22 @@
package templates package templates
import ( import (
"errors"
"net/http" "net/http"
"slices"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/asaskevich/govalidator"
"github.com/rs/zerolog/log" "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 { type fileResponse struct {
// The requested file content // The requested file content
FileContent string `example:"version:2"` 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 // @id TemplateFile
// @summary Get a template's file // @summary Get a template's file
// @description Get a template's file // @description Get a template's file
@ -76,21 +26,42 @@ func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperr
// @security jwt // @security jwt
// @accept json // @accept json
// @produce json // @produce json
// @param body body filePayload true "File details" // @param id path int true "Template identifier"
// @success 200 {object} fileResponse "Success" // @success 200 {object} fileResponse "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 500 "Server error" // @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 { func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload filePayload id, err := request.RetrieveNumericRouteVariableValue(r, "id")
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid request payload", err) return httperror.BadRequest("Invalid template identifier", err)
} }
if err := handler.ifRequestedTemplateExists(&payload); err != nil { templatesResponse, httpErr := handler.fetchTemplates()
return err 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() projectPath, err := handler.FileService.GetTemporaryPath()
@ -100,12 +71,12 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
defer handler.cleanUp(projectPath) defer handler.cleanUp(projectPath)
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false) err = handler.GitService.CloneRepository(projectPath, template.Repository.URL, "", "", "", false)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to clone git repository", err) 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 { if err != nil {
return httperror.InternalServerError("Failed loading file content", err) return httperror.InternalServerError("Failed loading file content", err)
} }

View File

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

View File

@ -1,19 +1,12 @@
package templates package templates
import ( import (
"io"
"net/http" "net/http"
portainer "github.com/portainer/portainer/api"
httperror "github.com/portainer/portainer/pkg/libhttp/error" 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 // @id TemplateList
// @summary List available templates // @summary List available templates
// @description List available templates. // @description List available templates.
@ -26,22 +19,10 @@ type listResponse struct {
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /templates [get] // @router /templates [get]
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.DataStore.Settings().Settings() templates, httpErr := handler.fetchTemplates()
if err != nil { if httpErr != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err) return httpErr
} }
resp, err := http.Get(settings.TemplatesURL) return response.JSON(w, templates)
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
} }

View File

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

View File

@ -1153,7 +1153,7 @@ type (
Template struct { Template struct {
// Mandatory container/stack fields // Mandatory container/stack fields
// Template Identifier // 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) // Template type. Valid values are: 1 (container), 2 (Swarm stack), 3 (Compose stack), 4 (Compose edge stack)
Type TemplateType `json:"type" example:"1"` Type TemplateType `json:"type" example:"1"`
// Title of the template // 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 represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
DefaultEdgeAgentCheckinIntervalInSeconds = 5 DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer // 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 represents the URL to the official templates supported by Bitnami
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami" DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
@ -1829,8 +1829,6 @@ const (
SwarmStackTemplate SwarmStackTemplate
// ComposeStackTemplate represents a template used to deploy a Compose stack // ComposeStackTemplate represents a template used to deploy a Compose stack
ComposeStackTemplate ComposeStackTemplate
// EdgeStackTemplate represents a template used to deploy an Edge stack
EdgeStackTemplate
) )
const ( const (

View File

@ -122,17 +122,13 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
const customTemplatesNew = { const customTemplatesNew = {
name: 'docker.templates.custom.new', name: 'docker.templates.custom.new',
url: '/new?fileContent&type', url: '/new?appTemplateId&type',
views: { views: {
'content@': { 'content@': {
component: 'createCustomTemplateView', component: 'createCustomTemplateView',
}, },
}, },
params: {
fileContent: '',
type: '',
},
}; };
const customTemplatesEdit = { const customTemplatesEdit = {

View File

@ -142,6 +142,16 @@ angular
}); });
} }
$stateRegistryProvider.register({
name: 'edge.templates',
url: '/templates?template',
views: {
'content@': {
component: 'edgeAppTemplatesView',
},
},
});
$stateRegistryProvider.register(edge); $stateRegistryProvider.register(edge);
$stateRegistryProvider.register(groups); $stateRegistryProvider.register(groups);

View File

@ -28,6 +28,7 @@ export const componentsModule = angular
'error', 'error',
'horizontal', 'horizontal',
'isGroupVisible', 'isGroupVisible',
'required',
]) ])
) )
.component( .component(

View File

@ -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 EdgeStacksListView } from '@/react/edge/edge-stacks/ListView';
import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView'; import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
import { templatesModule } from './templates';
export const viewsModule = angular export const viewsModule = angular
.module('portainer.edge.react.views', []) .module('portainer.edge.react.views', [templatesModule])
.component( .component(
'waitingRoomView', 'waitingRoomView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), []) r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])

View File

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

View File

@ -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'; import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
class DockerComposeFormController { class DockerComposeFormController {
@ -35,7 +36,7 @@ class DockerComposeFormController {
return this.$async(async () => { return this.$async(async () => {
this.formValues.StackFileContent = ''; this.formValues.StackFileContent = '';
try { try {
const fileContent = await this.EdgeTemplateService.edgeTemplate(template); const fileContent = await fetchFilePreview(template.id);
this.formValues.StackFileContent = fileContent; this.formValues.StackFileContent = fileContent;
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Template'); this.Notifications.error('Failure', err, 'Unable to retrieve Template');

View File

@ -0,0 +1,5 @@
import helm from '@/assets/ico/vendor/helm.svg?c';
import { BadgeIcon } from '@@/BadgeIcon';
export const HelmIcon = <BadgeIcon icon={helm} />;

View File

@ -3,7 +3,7 @@
<div class="blocklist-item-box"> <div class="blocklist-item-box">
<!-- helmchart-image --> <!-- helmchart-image -->
<span class="shrink-0"> <span class="shrink-0">
<fallback-image src="$ctrl.model.icon" fallback-icon="'svg-helm'" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image> <fallback-image src="$ctrl.model.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image>
</span> </span>
<!-- helmchart-details --> <!-- helmchart-details -->
<div class="col-sm-12 helm-template-item-details"> <div class="col-sm-12 helm-template-item-details">

View File

@ -1,5 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import './helm-templates-list-item.css'; import './helm-templates-list-item.css';
import { HelmIcon } from '../../HelmIcon';
angular.module('portainer.kubernetes').component('helmTemplatesListItem', { angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
templateUrl: './helm-templates-list-item.html', templateUrl: './helm-templates-list-item.html',
@ -10,4 +11,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
transclude: { transclude: {
actions: '?templateItemActions', actions: '?templateItemActions',
}, },
controller() {
this.fallbackIcon = HelmIcon;
},
}); });

View File

@ -1,7 +1,7 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { HelmIcon } from './HelmIcon';
export default class HelmTemplatesController { export default class HelmTemplatesController {
/* @ngInject */ /* @ngInject */
constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) { constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
@ -15,6 +15,8 @@ export default class HelmTemplatesController {
this.KubernetesResourcePoolService = KubernetesResourcePoolService; this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.fallbackIcon = HelmIcon;
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.uiCanExit = this.uiCanExit.bind(this); this.uiCanExit = this.uiCanExit.bind(this);
this.installHelmchart = this.installHelmchart.bind(this); this.installHelmchart = this.installHelmchart.bind(this);

View File

@ -5,7 +5,7 @@
<div class="flex"> <div class="flex">
<div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white"> <div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
<div class="vertical-center p-5"> <div class="vertical-center p-5">
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="'svg-helm'" class-name="'h-16 w-16'" size="'lg'"></fallback-image> <fallback-image src="$ctrl.state.chart.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
<div class="font-medium ml-4"> <div class="font-medium ml-4">
<div class="toolBarTitle text-[24px] mb-2"> <div class="toolBarTitle text-[24px] mb-2">
{{ $ctrl.state.chart.name }} {{ $ctrl.state.chart.name }}

View File

@ -38,6 +38,17 @@ export const ngModule = angular
'isVariablesNamesFromParent', 'isVariablesNamesFromParent',
]) ])
) )
.component(
'appTemplatesList',
r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
'onSelect',
'templates',
'selectedId',
'disabledTypes',
'fixedCategories',
'hideDuplicate',
])
)
.component( .component(
'customTemplatesList', 'customTemplatesList',
r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [ r2a(withUIRouter(withCurrentUser(CustomTemplatesList)), [
@ -54,15 +65,6 @@ export const ngModule = angular
.component( .component(
'customTemplatesTypeSelector', 'customTemplatesTypeSelector',
r2a(TemplateTypeSelector, ['onChange', 'value']) r2a(TemplateTypeSelector, ['onChange', 'value'])
)
.component(
'appTemplatesList',
r2a(withUIRouter(withCurrentUser(AppTemplatesList)), [
'onSelect',
'templates',
'selectedId',
'showSwarmStacks',
])
); );
withFormValidation( withFormValidation(

View File

@ -118,7 +118,7 @@ export const ngModule = angular
) )
.component( .component(
'fallbackImage', 'fallbackImage',
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className']) r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'className'])
) )
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin'])) .component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
.component( .component(

View File

@ -5,6 +5,7 @@ import { getTemplateVariables, intersectVariables } from '@/react/portainer/cust
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods'; import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation';
class CreateCustomTemplateViewController { class CreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -218,38 +219,43 @@ class CreateCustomTemplateViewController {
} }
async $onInit() { async $onInit() {
const applicationState = this.StateManager.getState(); return this.$async(async () => {
const applicationState = this.StateManager.getState();
this.state.endpointMode = applicationState.endpoint.mode; this.state.endpointMode = applicationState.endpoint.mode;
let stackType = 0; let stackType = 0;
if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') { if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
this.isDockerStandalone = true; this.isDockerStandalone = true;
stackType = 2; stackType = 2;
} else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') { } else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
stackType = 1; 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.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() { $onDestroy() {

View File

@ -270,9 +270,4 @@
<!-- container-form --> <!-- container-form -->
</div> </div>
<app-templates-list <app-templates-list templates="templates" on-select="(selectTemplate)" selected-id="state.selectedTemplate.Id" disabled-types="disabledTypes"></app-templates-list>
templates="templates"
on-select="(selectTemplate)"
selected-id="state.selectedTemplate.Id"
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
></app-templates-list>

View File

@ -1,4 +1,5 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel';
angular.module('portainer.app').controller('TemplatesController', [ angular.module('portainer.app').controller('TemplatesController', [
@ -48,6 +49,8 @@ angular.module('portainer.app').controller('TemplatesController', [
actionInProgress: false, actionInProgress: false,
}; };
$scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack];
$scope.formValues = { $scope.formValues = {
network: '', network: '',
name: '', name: '',
@ -282,6 +285,10 @@ angular.module('portainer.app').controller('TemplatesController', [
var apiVersion = $scope.applicationState.endpoint.apiVersion; var apiVersion = $scope.applicationState.endpoint.apiVersion;
const endpointId = +$state.params.endpointId; 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({ $q.all({
templates: TemplateService.templates(endpointId), templates: TemplateService.templates(endpointId),
volumes: VolumeService.getVolumes(), volumes: VolumeService.getVolumes(),

View File

@ -23,7 +23,7 @@ export function BlocklistItem<T extends ElementType>({
type="button" type="button"
className={clsx( className={clsx(
className, 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, 'blocklist-item--selected': isSelected,
} }

View File

@ -1,23 +1,14 @@
import { useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { BadgeIcon, BadgeSize } from './BadgeIcon/BadgeIcon';
interface Props { interface Props {
// props for the image to load // props for the image to load
src?: string; // a link to an external image src?: string; // a link to an external image
fallbackIcon: string; fallbackIcon: ReactNode;
alt?: string; alt?: string;
size?: BadgeSize;
className?: string; className?: string;
} }
export function FallbackImage({ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) {
src,
fallbackIcon,
alt,
size,
className,
}: Props) {
const [error, setError] = useState(false); const [error, setError] = useState(false);
useEffect(() => { useEffect(() => {
@ -36,5 +27,5 @@ export function FallbackImage({
} }
// fallback icon if there is an error loading the image // fallback icon if there is an error loading the image
return <BadgeIcon icon={fallbackIcon} size={size} />; return <>{fallbackIcon}</>;
} }

View File

@ -12,6 +12,7 @@ import { Select as ReactSelect } from '@@/form-components/ReactSelect';
export interface Option<TValue> { export interface Option<TValue> {
value: TValue; value: TValue;
label: string; label: string;
disabled?: boolean;
} }
type Options<TValue> = OptionsOrGroups< type Options<TValue> = OptionsOrGroups<
@ -99,6 +100,7 @@ export function SingleSelect<TValue = string>({
options={options} options={options}
value={selectedValue} value={selectedValue}
onChange={(option) => onChange(option ? option.value : null)} onChange={(option) => onChange(option ? option.value : null)}
isOptionDisabled={(option) => !!option.disabled}
data-cy={dataCy} data-cy={dataCy}
inputId={inputId} inputId={inputId}
placeholder={placeholder} placeholder={placeholder}
@ -155,6 +157,7 @@ export function MultiSelect<TValue = string>({
isClearable={isClearable} isClearable={isClearable}
getOptionLabel={(option) => option.label} getOptionLabel={(option) => option.label}
getOptionValue={(option) => String(option.value)} getOptionValue={(option) => String(option.value)}
isOptionDisabled={(option) => !!option.disabled}
options={options} options={options}
value={selectedOptions} value={selectedOptions}
closeMenuOnSelect={false} closeMenuOnSelect={false}

View File

@ -0,0 +1,50 @@
import { FormikErrors } from 'formik';
import { SchemaOf, string } from 'yup';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { EdgeStack } from '../types';
export function NameField({
onChange,
value,
errors,
}: {
onChange(value: string): void;
value: string;
errors?: FormikErrors<string>;
}) {
return (
<FormControl inputId="name-input" label="Name" errors={errors} required>
<Input
id="name-input"
onChange={(e) => onChange(e.target.value)}
value={value}
required
/>
</FormControl>
);
}
export function nameValidation(
stacks: Array<EdgeStack>,
isComposeStack: boolean | undefined
): SchemaOf<string> {
let schema = string()
.required('Name is required')
.test('unique', 'Name should be unique', (value) =>
stacks.every((s) => s.Name !== value)
);
if (isComposeStack) {
schema = schema.matches(
new RegExp(STACK_NAME_VALIDATION_REGEX),
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
);
}
return schema;
}

View File

@ -2,7 +2,7 @@ import _ from 'lodash';
import { notifyError } from '@/portainer/services/notifications'; import { notifyError } from '@/portainer/services/notifications';
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset'; 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 { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { FormValues } from './types'; import { FormValues } from './types';
@ -24,7 +24,7 @@ export function PrivateRegistryFieldsetWrapper({
stackName: string; stackName: string;
onFieldError: (message: string) => void; onFieldError: (message: string) => void;
}) { }) {
const dryRunMutation = useCreateStackFromFileContent(); const dryRunMutation = useCreateEdgeStackFromFileContent();
const registriesQuery = useRegistries(); const registriesQuery = useRegistries();

View File

@ -18,6 +18,7 @@ interface Props {
error?: string | string[]; error?: string | string[];
horizontal?: boolean; horizontal?: boolean;
isGroupVisible?(group: EdgeGroup): boolean; isGroupVisible?(group: EdgeGroup): boolean;
required?: boolean;
} }
export function EdgeGroupsSelector({ export function EdgeGroupsSelector({
@ -26,6 +27,7 @@ export function EdgeGroupsSelector({
error, error,
horizontal, horizontal,
isGroupVisible = () => true, isGroupVisible = () => true,
required,
}: Props) { }: Props) {
const selector = ( const selector = (
<InnerSelector <InnerSelector
@ -36,11 +38,11 @@ export function EdgeGroupsSelector({
); );
return horizontal ? ( return horizontal ? (
<FormControl errors={error} label="Edge Groups"> <FormControl errors={error} label="Edge Groups" required={required}>
{selector} {selector}
</FormControl> </FormControl>
) : ( ) : (
<FormSection title="Edge Groups"> <FormSection title={`Edge Groups${required ? ' *' : ''}`}>
<div className="form-group"> <div className="form-group">
<div className="col-sm-12">{selector} </div> <div className="col-sm-12">{selector} </div>
{error && ( {error && (

View File

@ -1,17 +1,21 @@
import { useMutation } from 'react-query'; import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; 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 { RegistryId } from '@/react/portainer/registries/types';
import { EdgeGroup } from '../../edge-groups/types'; import { EdgeGroup } from '../../edge-groups/types';
import { DeploymentType, EdgeStack } from '../types'; import { DeploymentType, EdgeStack } from '../types';
import { buildUrl } from './buildUrl'; import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useCreateStackFromFileContent() { export function useCreateEdgeStackFromFileContent() {
return useMutation(createStackFromFileContent, { const queryClient = useQueryClient();
return useMutation(createEdgeStackFromFileContent, {
...withError('Failed creating Edge stack'), ...withError('Failed creating Edge stack'),
...withInvalidate(queryClient, [queryKeys.base()]),
}); });
} }
@ -26,7 +30,7 @@ interface FileContentPayload {
dryRun?: boolean; dryRun?: boolean;
} }
export async function createStackFromFileContent({ export async function createEdgeStackFromFileContent({
dryRun, dryRun,
...payload ...payload
}: FileContentPayload) { }: FileContentPayload) {

View File

@ -0,0 +1,142 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { Pair } from '@/react/portainer/settings/types';
import { RegistryId } from '@/react/portainer/registries/types';
import { GitCredential } from '@/react/portainer/account/git-credentials/types';
import { DeploymentType, EdgeStack } from '../types';
import { EdgeGroup } from '../../edge-groups/types';
import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useCreateEdgeStackFromGit() {
const queryClient = useQueryClient();
return useMutation(createEdgeStackFromGit, {
...withError('Failed creating Edge stack'),
...withInvalidate(queryClient, [queryKeys.base()]),
});
}
/**
* Represents the payload for creating an edge stack from a Git repository.
*/
interface GitPayload {
/** Name of the stack. */
name: string;
/** URL of a Git repository hosting the Stack file. */
repositoryURL: string;
/** Reference name of a Git repository hosting the Stack file. */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository. */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** GitCredentialID used to identify the bound git credential. */
repositoryGitCredentialID?: GitCredential['id'];
/** Path to the Stack file inside the Git repository. */
filePathInRepository?: string;
/** List of identifiers of EdgeGroups. */
edgeGroups: Array<EdgeGroup['Id']>;
/** Deployment type to deploy this stack. */
deploymentType: DeploymentType;
/** List of Registries to use for this stack. */
registries?: RegistryId[];
/** Uses the manifest's namespaces instead of the default one. */
useManifestNamespaces?: boolean;
/** Pre-pull image. */
prePullImage?: boolean;
/** Retry deploy. */
retryDeploy?: boolean;
/** TLSSkipVerify skips SSL verification when cloning the Git repository. */
tLSSkipVerify?: boolean;
/** Optional GitOps update configuration. */
autoUpdate?: AutoUpdateModel;
/** Whether the stack supports relative path volume. */
supportRelativePath?: boolean;
/** Local filesystem path. */
filesystemPath?: string;
/** Whether the edge stack supports per device configs. */
supportPerDeviceConfigs?: boolean;
/** Per device configs match type. */
perDeviceConfigsMatchType?: 'file' | 'dir';
/** Per device configs group match type. */
perDeviceConfigsGroupMatchType?: 'file' | 'dir';
/** Per device configs path. */
perDeviceConfigsPath?: string;
/** List of environment variables. */
envVars?: Pair[];
/** Configuration for stagger updates. */
staggerConfig?: EdgeStaggerConfig;
}
/**
* Represents the staggered updates configuration.
*/
interface EdgeStaggerConfig {
/** Stagger option for updates. */
staggerOption: EdgeStaggerOption;
/** Stagger parallel option for updates. */
staggerParallelOption: EdgeStaggerParallelOption;
/** Device number for updates. */
deviceNumber: number;
/** Starting device number for updates. */
deviceNumberStartFrom: number;
/** Increment value for device numbers during updates. */
deviceNumberIncrementBy: number;
/** Timeout for updates (in minutes). */
timeout: string;
/** Update delay (in minutes). */
updateDelay: string;
/** Action to take in case of update failure. */
updateFailureAction: EdgeUpdateFailureAction;
}
/** EdgeStaggerOption represents an Edge stack stagger option */
enum EdgeStaggerOption {
/** AllAtOnce represents a staggered deployment where all nodes are updated at once */
AllAtOnce = 1,
/** OneByOne represents a staggered deployment where nodes are updated with parallel setting */
Parallel,
}
/** EdgeStaggerParallelOption represents an Edge stack stagger parallel option */
enum EdgeStaggerParallelOption {
/** Fixed represents a staggered deployment where nodes are updated with a fixed number of nodes in parallel */
Fixed = 1,
/** Incremental represents a staggered deployment where nodes are updated with an incremental number of nodes in parallel */
Incremental,
}
/** EdgeUpdateFailureAction represents an Edge stack update failure action */
enum EdgeUpdateFailureAction {
/** Continue represents that stagger update will continue regardless of whether the endpoint update status */
Continue = 1,
/** Pause represents that stagger update will pause when the endpoint update status is failed */
Pause,
/** Rollback represents that stagger update will rollback as long as one endpoint update status is failed */
Rollback,
}
export async function createEdgeStackFromGit({
dryRun,
...payload
}: GitPayload & { dryRun?: boolean }) {
try {
const { data } = await axios.post<EdgeStack>(
buildUrl(undefined, 'create/repository'),
payload,
{
params: { dryrun: dryRun ? 'true' : 'false' },
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error);
}
}

View File

@ -0,0 +1,41 @@
import { useParamState } from '@/react/hooks/useParamState';
import { AppTemplatesList } from '@/react/portainer/templates/app-templates/AppTemplatesList';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { PageHeader } from '@@/PageHeader';
import { DeployFormWidget } from './DeployForm';
export function AppTemplatesView() {
const [selectedTemplateId, setSelectedTemplateId] = useParamState<number>(
'template',
(param) => (param ? parseInt(param, 10) : 0)
);
const templatesQuery = useAppTemplates();
const selectedTemplate = selectedTemplateId
? templatesQuery.data?.find(
(template) => template.Id === selectedTemplateId
)
: undefined;
return (
<>
<PageHeader title="Application templates list" breadcrumbs="Templates" />
{selectedTemplate && (
<DeployFormWidget
template={selectedTemplate}
unselect={() => setSelectedTemplateId()}
/>
)}
<AppTemplatesList
templates={templatesQuery.data}
selectedId={selectedTemplateId}
onSelect={(template) => setSelectedTemplateId(template.Id)}
disabledTypes={[TemplateType.Container]}
fixedCategories={['edge']}
hideDuplicate
/>
</>
);
}

View File

@ -0,0 +1,190 @@
import { Rocket } from 'lucide-react';
import { Form, Formik } from 'formik';
import { array, lazy, number, object, string } from 'yup';
import { useRouter } from '@uirouter/react';
import _ from 'lodash';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { FallbackImage } from '@@/FallbackImage';
import { Icon } from '@@/Icon';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
import {
NameField,
nameValidation,
} from '../../edge-stacks/CreateView/NameField';
import { EdgeGroup } from '../../edge-groups/types';
import { DeploymentType, EdgeStack } from '../../edge-stacks/types';
import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
import { useCreateEdgeStackFromGit } from '../../edge-stacks/queries/useCreateEdgeStackFromGit';
import { EnvVarsFieldset } from './EnvVarsFieldset';
export function DeployFormWidget({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title
icon={
<FallbackImage
src={template.Logo}
fallbackIcon={<Icon icon={Rocket} />}
/>
}
title={template.Title}
/>
<Widget.Body>
<DeployForm template={template} unselect={unselect} />
</Widget.Body>
</Widget>
</div>
</div>
);
}
interface FormValues {
name: string;
edgeGroupIds: Array<EdgeGroup['Id']>;
envVars: Record<string, string>;
}
function DeployForm({
template,
unselect,
}: {
template: TemplateViewModel;
unselect: () => void;
}) {
const router = useRouter();
const mutation = useCreateEdgeStackFromGit();
const edgeStacksQuery = useEdgeStacks();
const edgeGroupsQuery = useEdgeGroups({
select: (groups) =>
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
});
const initialValues: FormValues = {
edgeGroupIds: [],
name: template.Name || '',
envVars:
Object.fromEntries(template.Env?.map((env) => [env.name, env.value])) ||
{},
};
if (!edgeStacksQuery.data || !edgeGroupsQuery.data) {
return null;
}
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={() =>
validation(edgeStacksQuery.data, edgeGroupsQuery.data)
}
validateOnMount
>
{({ values, errors, setFieldValue, isValid }) => (
<Form className="form-horizontal">
<NameField
value={values.name}
onChange={(v) => setFieldValue('name', v)}
errors={errors.name}
/>
<EdgeGroupsSelector
horizontal
value={values.edgeGroupIds}
error={errors.edgeGroupIds}
onChange={(value) => setFieldValue('edgeGroupIds', value)}
required
/>
<EnvVarsFieldset
value={values.envVars}
options={template.Env}
errors={errors.envVars}
onChange={(values) => setFieldValue('envVars', values)}
/>
<FormActions
isLoading={mutation.isLoading}
isValid={isValid}
loadingText="Deployment in progress..."
submitLabel="Deploy the stack"
>
<Button type="reset" onClick={() => unselect()} color="default">
Hide
</Button>
</FormActions>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
return mutation.mutate(
{
name: values.name,
edgeGroups: values.edgeGroupIds,
deploymentType: DeploymentType.Compose,
repositoryURL: template.Repository.url,
filePathInRepository: template.Repository.stackfile,
envVars: Object.entries(values.envVars).map(([name, value]) => ({
name,
value,
})),
},
{
onSuccess() {
notifySuccess('Success', 'Edge Stack created');
router.stateService.go('edge.stacks');
},
}
);
}
}
function validation(
stacks: EdgeStack[],
edgeGroupsType: Record<EdgeGroup['Id'], Array<EnvironmentType>>
) {
return lazy((values: FormValues) => {
const types = getTypes(values.edgeGroupIds);
return object({
name: nameValidation(
stacks,
types?.includes(EnvironmentType.EdgeAgentOnDocker)
),
edgeGroupIds: array(number().required().default(0))
.min(1, 'At least one group is required')
.test(
'same-type',
'Groups should be of the same type',
(value) => _.uniq(getTypes(value)).length === 1
),
envVars: array()
.transform((_, orig) => Object.values(orig))
.of(string().required('Required')),
});
});
function getTypes(value: number[] | undefined) {
return value?.flatMap((g) => edgeGroupsType[g]);
}
}

View File

@ -0,0 +1,76 @@
import { FormikErrors } from 'formik';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
type Value = Record<string, string>;
export function EnvVarsFieldset({
onChange,
options,
value,
errors,
}: {
options: Array<TemplateEnv>;
onChange: (value: Value) => void;
value: Value;
errors?: FormikErrors<Value>;
}) {
return (
<>
{options.map((env, index) => (
<Item
key={env.name}
option={env}
value={value[env.name]}
onChange={(value) => handleChange(env.name, value)}
errors={errors?.[index]}
/>
))}
</>
);
function handleChange(name: string, envValue: string) {
onChange({ ...value, [name]: envValue });
}
}
function Item({
onChange,
option,
value,
errors,
}: {
option: TemplateEnv;
value: string;
onChange: (value: string) => void;
errors?: FormikErrors<string>;
}) {
return (
<FormControl
label={option.label || option.name}
required={!option.preset}
errors={errors}
>
{option.select ? (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
options={option.select.map((o) => ({
label: o.text,
value: o.value,
}))}
disabled={option.preset}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={option.preset}
/>
)}
</FormControl>
);
}

View File

@ -0,0 +1 @@
export { AppTemplatesView } from './AppTemplatesView';

View File

@ -2,7 +2,8 @@ import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
export function useParamState<T>( export function useParamState<T>(
param: string, param: string,
parseParam: (param: string | undefined) => T | undefined parseParam: (param: string | undefined) => T | undefined = (param) =>
param as unknown as T
) { ) {
const { const {
params: { [param]: paramValue }, params: { [param]: paramValue },
@ -12,7 +13,7 @@ export function useParamState<T>(
return [ return [
state, state,
(value: T | undefined) => { (value?: T) => {
router.stateService.go('.', { [param]: value }); router.stateService.go('.', { [param]: value });
}, },
] as const; ] as const;

View File

@ -23,12 +23,11 @@ export function TemplatesUrlSection() {
</span> </span>
</div> </div>
<FormControl label="URL" inputId="templates_url" required errors={error}> <FormControl label="URL" inputId="templates_url" errors={error}>
<Field <Field
as={Input} as={Input}
id="templates_url" id="templates_url"
placeholder="https://myserver.mydomain/templates.json" placeholder="https://myserver.mydomain/templates.json"
required
data-cy="settings-templateUrl" data-cy="settings-templateUrl"
name={name} name={name}
/> />

View File

@ -7,7 +7,7 @@ import { Values } from './types';
export function validation(): SchemaOf<Values> { export function validation(): SchemaOf<Values> {
return object({ return object({
edgeAgentCheckinInterval: number().required(), edgeAgentCheckinInterval: number().required(),
enableTelemetry: bool().required(), enableTelemetry: bool().default(false),
loginBannerEnabled: boolean().default(false), loginBannerEnabled: boolean().default(false),
loginBanner: string() loginBanner: string()
.default('') .default('')
@ -30,7 +30,11 @@ export function validation(): SchemaOf<Values> {
}), }),
snapshotInterval: string().required('Snapshot interval is required'), snapshotInterval: string().required('Snapshot interval is required'),
templatesUrl: string() templatesUrl: string()
.required('Templates URL is required') .default('')
.test('valid-url', 'Must be a valid URL', (value) => isValidUrl(value)), .test(
'valid-url',
'Must be a valid URL',
(value) => !value || isValidUrl(value)
),
}); });
} }

View File

@ -1,7 +1,6 @@
import { Edit } from 'lucide-react'; import { Edit } from 'lucide-react';
import _ from 'lodash'; import _ from 'lodash';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from '@uirouter/react';
import { DatatableHeader } from '@@/datatables/DatatableHeader'; import { DatatableHeader } from '@@/datatables/DatatableHeader';
import { Table } from '@@/datatables'; import { Table } from '@@/datatables';
@ -11,39 +10,41 @@ import { DatatableFooter } from '@@/datatables/DatatableFooter';
import { AppTemplatesListItem } from './AppTemplatesListItem'; import { AppTemplatesListItem } from './AppTemplatesListItem';
import { TemplateViewModel } from './view-model'; import { TemplateViewModel } from './view-model';
import { ListState } from './types'; import { ListState, TemplateType } from './types';
import { useSortAndFilterTemplates } from './useSortAndFilter'; import { useSortAndFilterTemplates } from './useSortAndFilter';
import { Filters } from './Filters'; import { Filters } from './Filters';
import { useFetchTemplateInfoMutation } from './useFetchTemplateInfoMutation';
const tableKey = 'app-templates-list'; const tableKey = 'app-templates-list';
const store = createPersistedStore<ListState>(tableKey, undefined, (set) => ({ const store = createPersistedStore<ListState>(tableKey, undefined, (set) => ({
category: null, category: null,
setCategory: (category: ListState['category']) => set({ category }), setCategory: (category: ListState['category']) => set({ category }),
type: null, types: [],
setType: (type: ListState['type']) => set({ type }), setTypes: (types: ListState['types']) => set({ types }),
})); }));
export function AppTemplatesList({ export function AppTemplatesList({
templates, templates,
onSelect, onSelect,
selectedId, selectedId,
showSwarmStacks, disabledTypes,
fixedCategories,
hideDuplicate,
}: { }: {
templates?: TemplateViewModel[]; templates?: TemplateViewModel[];
onSelect: (template: TemplateViewModel) => void; onSelect: (template: TemplateViewModel) => void;
selectedId?: TemplateViewModel['Id']; selectedId?: TemplateViewModel['Id'];
showSwarmStacks?: boolean; disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>;
hideDuplicate?: boolean;
}) { }) {
const fetchTemplateInfoMutation = useFetchTemplateInfoMutation();
const router = useRouter();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const listState = useTableState(store, tableKey); const listState = useTableState(store, tableKey);
const filteredTemplates = useSortAndFilterTemplates( const filteredTemplates = useSortAndFilterTemplates(
templates || [], templates || [],
listState, listState,
showSwarmStacks disabledTypes,
fixedCategories
); );
const pagedTemplates = const pagedTemplates =
@ -59,8 +60,10 @@ export function AppTemplatesList({
description={ description={
<Filters <Filters
listState={listState} listState={listState}
templates={templates || []} templates={filteredTemplates || []}
onChange={() => setPage(0)} onChange={() => setPage(0)}
disabledTypes={disabledTypes}
fixedCategories={fixedCategories}
/> />
} }
/> />
@ -71,8 +74,8 @@ export function AppTemplatesList({
key={template.Id} key={template.Id}
template={template} template={template}
onSelect={onSelect} onSelect={onSelect}
onDuplicate={onDuplicate}
isSelected={selectedId === template.Id} isSelected={selectedId === template.Id}
hideDuplicate={hideDuplicate}
/> />
))} ))}
{!templates && <div className="text-muted text-center">Loading...</div>} {!templates && <div className="text-muted text-center">Loading...</div>}
@ -96,15 +99,4 @@ export function AppTemplatesList({
listState.setSearch(search); listState.setSearch(search);
setPage(0); setPage(0);
} }
function onDuplicate(template: TemplateViewModel) {
fetchTemplateInfoMutation.mutate(template, {
onSuccess({ fileContent, type }) {
router.stateService.go('.custom.new', {
fileContent,
type,
});
},
});
}
} }

View File

@ -1,4 +1,7 @@
import { StackType } from '@/react/common/stacks/types';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { TemplateItem } from '../components/TemplateItem'; import { TemplateItem } from '../components/TemplateItem';
@ -8,14 +11,16 @@ import { TemplateType } from './types';
export function AppTemplatesListItem({ export function AppTemplatesListItem({
template, template,
onSelect, onSelect,
onDuplicate,
isSelected, isSelected,
hideDuplicate = false,
}: { }: {
template: TemplateViewModel; template: TemplateViewModel;
onSelect: (template: TemplateViewModel) => void; onSelect: (template: TemplateViewModel) => void;
onDuplicate: (template: TemplateViewModel) => void;
isSelected: boolean; isSelected: boolean;
hideDuplicate?: boolean;
}) { }) {
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
return ( return (
<TemplateItem <TemplateItem
template={template} template={template}
@ -25,21 +30,39 @@ export function AppTemplatesListItem({
onSelect={() => onSelect(template)} onSelect={() => onSelect(template)}
isSelected={isSelected} isSelected={isSelected}
renderActions={ renderActions={
template.Type === TemplateType.SwarmStack || !hideDuplicate &&
(template.Type === TemplateType.ComposeStack && ( duplicateCustomTemplateType && (
<div className="mr-5 mt-3"> <div className="mr-5 mt-3">
<Button <Button
as={Link}
size="xsmall" size="xsmall"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDuplicate(template); }}
props={{
to: '.custom.new',
params: {
appTemplateId: template.Id,
type: duplicateCustomTemplateType,
},
}} }}
> >
Copy as Custom Copy as Custom
</Button> </Button>
</div> </div>
)) )
} }
/> />
); );
} }
function getCustomTemplateType(type: TemplateType): StackType | null {
switch (type) {
case TemplateType.SwarmStack:
return StackType.DockerSwarm;
case TemplateType.ComposeStack:
return StackType.DockerCompose;
default:
return null;
}
}

View File

@ -1,58 +1,79 @@
import _ from 'lodash'; import _ from 'lodash';
import { PortainerSelect } from '@@/form-components/PortainerSelect'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { ListState, TemplateType } from './types'; import { ListState, TemplateType } from './types';
import { TemplateViewModel } from './view-model'; import { TemplateViewModel } from './view-model';
import { TemplateListSort } from './TemplateListSort'; import { TemplateListSort } from './TemplateListSort';
const orderByFields = ['Title', 'Categories', 'Description'] as const; const orderByFields = ['Title', 'Categories', 'Description'] as const;
const typeFilters = [ const typeFilters: ReadonlyArray<Option<TemplateType>> = [
{ label: 'Container', value: TemplateType.Container }, { label: 'Container', value: TemplateType.Container },
{ label: 'Stack', value: TemplateType.SwarmStack }, { label: 'Swarm Stack', value: TemplateType.SwarmStack },
{ label: 'Compose Stack', value: TemplateType.ComposeStack },
] as const; ] as const;
export function Filters({ export function Filters({
templates, templates,
listState, listState,
onChange, onChange,
disabledTypes = [],
fixedCategories = [],
}: { }: {
templates: TemplateViewModel[]; templates: TemplateViewModel[];
listState: ListState & { search: string }; listState: ListState & { search: string };
onChange(): void; onChange(): void;
disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>;
}) { }) {
const categories = _.sortBy( const categories = _.sortBy(
_.uniq(templates?.flatMap((template) => template.Categories)) _.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 ( return (
<div className="flex gap-4 w-full"> <div className="flex gap-4 w-full">
<div className="w-1/4"> {categories.length > 0 && (
<PortainerSelect <div className="w-1/4">
options={categories} <PortainerSelect
onChange={(category) => { options={categories}
listState.setCategory(category); onChange={(category) => {
onChange(); listState.setCategory(category);
}} onChange();
placeholder="Category" }}
value={listState.category} placeholder="Category"
bindToBody value={listState.category}
isClearable bindToBody
/> isClearable
</div> />
<div className="w-1/4"> </div>
<PortainerSelect )}
options={typeFilters} {typeFiltersEnabled.length > 1 && (
onChange={(type) => { <div className="w-1/4">
listState.setType(type); <PortainerSelect<TemplateType>
onChange(); isMulti
}} options={typeFiltersEnabled}
placeholder="Type" onChange={(types) => {
value={listState.type} listState.setTypes(types);
bindToBody onChange();
isClearable }}
/> placeholder="Type"
</div> value={listState.types}
bindToBody
isClearable
/>
</div>
)}
<div className="w-1/4 ml-auto"> <div className="w-1/4 ml-auto">
<TemplateListSort <TemplateListSort
onChange={(value) => { onChange={(value) => {

View File

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

View File

@ -0,0 +1,54 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { DockerHubViewModel } from '@/portainer/models/dockerhub';
import { Registry } from '@/react/portainer/registries/types/registry';
import { AppTemplate } from '../types';
import { TemplateViewModel } from '../view-model';
import { buildUrl } from './build-url';
export function useAppTemplates() {
const registriesQuery = useRegistries();
return useQuery(
'templates',
() => getTemplatesWithRegistry(registriesQuery.data),
{
enabled: !!registriesQuery.data,
}
);
}
async function getTemplatesWithRegistry(
registries: Array<Registry> | undefined
) {
if (!registries) {
return [];
}
const { templates, version } = await getTemplates();
return templates.map((item) => {
const template = new TemplateViewModel(item, version);
const registryURL = item.registry;
const registry = registryURL
? registries.find((reg) => reg.URL === registryURL)
: new DockerHubViewModel();
template.RegistryModel.Registry = registry || new DockerHubViewModel();
return template;
});
}
async function getTemplates() {
try {
const { data } = await axios.get<{
version: string;
templates: Array<AppTemplate>;
}>(buildUrl());
return data;
} catch (err) {
throw parseAxiosError(err);
}
}

View File

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

View File

@ -5,15 +5,14 @@ import { Pair } from '../../settings/types';
export interface ListState extends BasicTableSettings { export interface ListState extends BasicTableSettings {
category: string | null; category: string | null;
setCategory: (category: string | null) => void; setCategory: (category: string | null) => void;
type: TemplateType | null; types: ReadonlyArray<TemplateType>;
setType: (type: TemplateType | null) => void; setTypes: (value: ReadonlyArray<TemplateType>) => void;
} }
export enum TemplateType { export enum TemplateType {
Container = 1, Container = 1,
SwarmStack = 2, SwarmStack = 2,
ComposeStack = 3, ComposeStack = 3,
EdgeStack = 4,
} }
/** /**
@ -21,7 +20,12 @@ export enum TemplateType {
*/ */
export interface AppTemplate { 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 * @example 1
*/ */
type: TemplateType; type: TemplateType;

View File

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

View File

@ -1,22 +1,26 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import _ from 'lodash';
import { TemplateViewModel } from './view-model'; import { TemplateViewModel } from './view-model';
import { ListState, TemplateType } from './types'; import { ListState } from './types';
export function useSortAndFilterTemplates( export function useSortAndFilterTemplates(
templates: Array<TemplateViewModel>, templates: Array<TemplateViewModel>,
listState: ListState & { search: string }, listState: ListState & { search: string },
showSwarmStacks?: boolean disabledTypes: Array<TemplateViewModel['Type']> = [],
fixedCategories: Array<string> = []
) { ) {
const filterByCategory = useCallback( const filterByCategory = useCallback(
(item: TemplateViewModel) => { (item: TemplateViewModel) => {
if (!listState.category) { if (!listState.category && !fixedCategories.length) {
return true; 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( const filterBySearch = useCallback(
@ -37,29 +41,20 @@ export function useSortAndFilterTemplates(
const filterByTemplateType = useCallback( const filterByTemplateType = useCallback(
(item: TemplateViewModel) => { (item: TemplateViewModel) => {
switch (item.Type) { if (listState.types.length === 0 && disabledTypes.length === 0) {
case TemplateType.Container: return true;
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) {
return !disabledTypes.includes(item.Type);
}
return (
listState.types.includes(item.Type) &&
!disabledTypes.includes(item.Type)
);
}, },
[listState.type, showSwarmStacks] [disabledTypes, listState.types]
); );
const sort = useCallback( const sort = useCallback(

View File

@ -12,7 +12,7 @@ import {
} from './types'; } from './types';
export class TemplateViewModel { export class TemplateViewModel {
Id!: string; Id!: number;
Title!: string; Title!: string;
@ -65,46 +65,56 @@ export class TemplateViewModel {
protocol: string; protocol: string;
}[]; }[];
constructor(data: AppTemplate, version: string) { constructor(template: AppTemplate, version: string) {
switch (version) { switch (version) {
case '2': case '2':
this.setTemplatesV2(data); setTemplatesV2.call(this, template);
break;
case '3':
setTemplatesV3.call(this, template);
break; break;
default: default:
throw new Error('Unsupported template version'); throw new Error('Unsupported template version');
} }
} }
}
setTemplatesV2(template: AppTemplate) { function setTemplatesV3(this: TemplateViewModel, template: AppTemplate) {
this.Id = _.uniqueId(); setTemplatesV2.call(this, template);
this.Title = template.title; this.Id = template.id;
this.Type = template.type; }
this.Description = template.description;
this.AdministratorOnly = template.administrator_only; let templateV2ID = 0;
this.Name = template.name;
this.Note = template.note; function setTemplatesV2(this: TemplateViewModel, template: AppTemplate) {
this.Categories = template.categories ? template.categories : []; this.Id = templateV2ID++;
this.Platform = getPlatform(template.platform); this.Title = template.title;
this.Logo = template.logo; this.Type = template.type;
this.Repository = template.repository; this.Description = template.description;
this.Hostname = template.hostname; this.AdministratorOnly = template.administrator_only;
this.RegistryModel = new PorImageRegistryModel(); this.Name = template.name;
this.RegistryModel.Image = template.image; this.Note = template.note;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment this.Categories = template.categories ? template.categories : [];
// @ts-ignore this.Platform = getPlatform(template.platform);
this.RegistryModel.Registry.URL = template.registry || ''; this.Logo = template.logo;
this.Command = template.command ? template.command : ''; this.Repository = template.repository;
this.Network = template.network ? template.network : ''; this.Hostname = template.hostname;
this.Privileged = template.privileged ? template.privileged : false; this.RegistryModel = new PorImageRegistryModel();
this.Interactive = template.interactive ? template.interactive : false; this.RegistryModel.Image = template.image;
this.RestartPolicy = template.restart_policy // eslint-disable-next-line @typescript-eslint/ban-ts-comment
? template.restart_policy // @ts-ignore
: 'always'; this.RegistryModel.Registry.URL = template.registry || '';
this.Labels = template.labels ? template.labels : []; this.Command = template.command ? template.command : '';
this.Env = templateEnv(template); this.Network = template.network ? template.network : '';
this.Volumes = templateVolumes(template); this.Privileged = template.privileged ? template.privileged : false;
this.Ports = templatePorts(template); 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) { function templatePorts(data: AppTemplate) {

View File

@ -1,4 +1,5 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Rocket } from 'lucide-react';
import LinuxIcon from '@/assets/ico/linux.svg?c'; import LinuxIcon from '@/assets/ico/linux.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft.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 { Icon } from '@@/Icon';
import { FallbackImage } from '@@/FallbackImage'; import { FallbackImage } from '@@/FallbackImage';
import { BlocklistItem } from '@@/Blocklist/BlocklistItem'; import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
import { BadgeIcon } from '@@/BadgeIcon';
import { Platform } from '../../custom-templates/types'; import { Platform } from '../../custom-templates/types';
@ -38,9 +40,8 @@ export function TemplateItem({
<div className="vertical-center min-w-[56px] justify-center"> <div className="vertical-center min-w-[56px] justify-center">
<FallbackImage <FallbackImage
src={template.Logo} src={template.Logo}
fallbackIcon="rocket" fallbackIcon={<BadgeIcon icon={Rocket} size="3xl" />}
className="blocklist-item-logo" className="blocklist-item-logo"
size="3xl"
/> />
</div> </div>
<div className="col-sm-12 flex justify-between flex-wrap"> <div className="col-sm-12 flex justify-between flex-wrap">

View File

@ -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 { isBE } from '../portainer/feature-flags/feature-flags.service';
import { useSettings } from '../portainer/settings/queries'; import { useSettings } from '../portainer/settings/queries';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection'; import { SidebarSection } from './SidebarSection';
import { SidebarParent } from './SidebarItem/SidebarParent';
export function EdgeComputeSidebar() { export function EdgeComputeSidebar() {
// this sidebar is rendered only for admins, so we can safely assume that settingsQuery will succeed // 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" data-cy="portainerSidebar-edgeDevicesWaitingRoom"
/> />
)} )}
<SidebarParent
icon={Edit}
label="Templates"
to="edge.templates"
data-cy="edgeSidebar-templates"
>
<SidebarItem
label="Application"
to="edge.templates"
ignorePaths={['edge.templates.custom']}
isSubMenu
data-cy="edgeSidebar-appTemplates"
/>
{/* <SidebarItem
label="Custom"
to="edge.templates.custom"
isSubMenu
data-cy="edgeSidebar-customTemplates"
/> */}
</SidebarParent>
</SidebarSection> </SidebarSection>
); );
} }