feat(edge/templates): introduce custom templates [EE-6208] (#10561)

pull/10458/head
Chaim Lev-Ari 2023-11-15 10:45:07 +02:00 committed by GitHub
parent a0f583a17d
commit 68950fbb24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 2047 additions and 334 deletions

View File

@ -103,6 +103,8 @@ type customTemplateFromFileContentPayload struct {
FileContent string `validate:"required"` FileContent string `validate:"required"`
// Definitions of variables in the stack file // Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition Variables []portainer.CustomTemplateVariableDefinition
// EdgeTemplate indicates if this template purpose for Edge Stack
EdgeTemplate bool `example:"false"`
} }
func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error {
@ -169,6 +171,7 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p
Type: (payload.Type), Type: (payload.Type),
Logo: payload.Logo, Logo: payload.Logo,
Variables: payload.Variables, Variables: payload.Variables,
EdgeTemplate: payload.EdgeTemplate,
} }
templateFolder := strconv.Itoa(customTemplateID) templateFolder := strconv.Itoa(customTemplateID)
@ -218,6 +221,8 @@ type customTemplateFromGitRepositoryPayload struct {
TLSSkipVerify bool `example:"false"` TLSSkipVerify bool `example:"false"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"` IsComposeFormat bool `example:"false"`
// EdgeTemplate indicates if this template purpose for Edge Stack
EdgeTemplate bool `example:"false"`
} }
func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error {
@ -264,7 +269,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
// @success 200 {object} portainer.CustomTemplate // @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /custom_templates/repository [post] // @router /custom_templates/create/repository [post]
func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) { func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) {
var payload customTemplateFromGitRepositoryPayload var payload customTemplateFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
@ -283,6 +288,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
Logo: payload.Logo, Logo: payload.Logo,
Variables: payload.Variables, Variables: payload.Variables,
IsComposeFormat: payload.IsComposeFormat, IsComposeFormat: payload.IsComposeFormat,
EdgeTemplate: payload.EdgeTemplate,
} }
getProjectPath := func() string { getProjectPath := func() string {
@ -367,6 +373,8 @@ type customTemplateFromFileUploadPayload struct {
FileContent []byte FileContent []byte
// Definitions of variables in the stack file // Definitions of variables in the stack file
Variables []portainer.CustomTemplateVariableDefinition Variables []portainer.CustomTemplateVariableDefinition
// EdgeTemplate indicates if this template purpose for Edge Stack
EdgeTemplate bool `example:"false"`
} }
func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error { func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error {
@ -419,8 +427,15 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
if err != nil { if err != nil {
return errors.New("Invalid variables. Ensure that the variables are valid JSON") return errors.New("Invalid variables. Ensure that the variables are valid JSON")
} }
return validateVariablesDefinitions(payload.Variables) err = validateVariablesDefinitions(payload.Variables)
if err != nil {
return err
} }
}
edgeTemplate, _ := request.RetrieveBooleanMultiPartFormValue(r, "EdgeTemplate", true)
payload.EdgeTemplate = edgeTemplate
return nil return nil
} }
@ -444,7 +459,7 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
// @success 200 {object} portainer.CustomTemplate // @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /custom_templates/file [post] // @router /custom_templates/create/file [post]
func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) { func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) {
payload := &customTemplateFromFileUploadPayload{} payload := &customTemplateFromFileUploadPayload{}
err := payload.Validate(r) err := payload.Validate(r)
@ -463,6 +478,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
Logo: payload.Logo, Logo: payload.Logo,
EntryPoint: filesystem.ComposeFileDefaultName, EntryPoint: filesystem.ComposeFileDefaultName,
Variables: payload.Variables, Variables: payload.Variables,
EdgeTemplate: payload.EdgeTemplate,
} }
templateFolder := strconv.Itoa(customTemplateID) templateFolder := strconv.Itoa(customTemplateID)

View File

@ -59,6 +59,8 @@ type customTemplateUpdatePayload struct {
TLSSkipVerify bool `example:"false"` TLSSkipVerify bool `example:"false"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"` IsComposeFormat bool `example:"false"`
// EdgeTemplate indicates if this template purpose for Edge Stack
EdgeTemplate bool `example:"false"`
} }
func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
@ -161,6 +163,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.Type = payload.Type customTemplate.Type = payload.Type
customTemplate.Variables = payload.Variables customTemplate.Variables = payload.Variables
customTemplate.IsComposeFormat = payload.IsComposeFormat customTemplate.IsComposeFormat = payload.IsComposeFormat
customTemplate.EdgeTemplate = payload.EdgeTemplate
if payload.RepositoryURL != "" { if payload.RepositoryURL != "" {
if !govalidator.IsURL(payload.RepositoryURL) { if !govalidator.IsURL(payload.RepositoryURL) {

View File

@ -18,6 +18,7 @@ type templateFileFormat struct {
} }
// @id EdgeTemplateList // @id EdgeTemplateList
// @deprecated
// @summary Fetches the list of Edge Templates // @summary Fetches the list of Edge Templates
// @description **Access policy**: administrator // @description **Access policy**: administrator
// @tags edge_templates // @tags edge_templates

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@ -25,7 +26,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
} }
h.Handle("/edge_templates", h.Handle("/edge_templates",
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeTemplateList))).Methods(http.MethodGet) bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
return h return h
} }

View File

@ -16,6 +16,12 @@ func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *h
return return
} }
if newUrl == "" {
log.Warn().Msg("This api is deprecated.")
router.ServeHTTP(w, r)
return
}
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl) log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
redirectedRequest := r.Clone(r.Context()) redirectedRequest := r.Clone(r.Context())

View File

@ -187,6 +187,8 @@ type (
GitConfig *gittypes.RepoConfig `json:"GitConfig"` GitConfig *gittypes.RepoConfig `json:"GitConfig"`
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
IsComposeFormat bool `example:"false"` IsComposeFormat bool `example:"false"`
// EdgeTemplate indicates if this template purpose for Edge Stack
EdgeTemplate bool `example:"false"`
} }
// CustomTemplateID represents a custom template identifier // CustomTemplateID represents a custom template identifier

View File

@ -62,7 +62,7 @@ angular
const stacksNew = { const stacksNew = {
name: 'edge.stacks.new', name: 'edge.stacks.new',
url: '/new', url: '/new?templateId',
views: { views: {
'content@': { 'content@': {
component: 'createEdgeStackView', component: 'createEdgeStackView',
@ -150,6 +150,44 @@ angular
component: 'edgeAppTemplatesView', component: 'edgeAppTemplatesView',
}, },
}, },
data: {
docs: '/user/edge/templates',
},
});
$stateRegistryProvider.register({
name: 'edge.templates.custom',
url: '/custom',
views: {
'content@': {
component: 'edgeCustomTemplatesView',
},
},
data: {
docs: '/user/edge/templates/custom',
},
});
$stateRegistryProvider.register({
name: 'edge.templates.custom.new',
url: '/new?appTemplateId&type',
views: {
'content@': {
component: 'edgeCreateCustomTemplatesView',
},
},
});
$stateRegistryProvider.register({
name: 'edge.templates.custom.edit',
url: '/:templateId',
views: {
'content@': {
component: 'edgeEditCustomTemplatesView',
},
},
}); });
$stateRegistryProvider.register(edge); $stateRegistryProvider.register(edge);

View File

@ -13,6 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable'; import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
export const componentsModule = angular export const componentsModule = angular
.module('portainer.edge.react.components', []) .module('portainer.edge.react.components', [])
@ -99,4 +100,8 @@ export const componentsModule = angular
'onChange', 'onChange',
'value', 'value',
]) ])
)
.component(
'edgeStackCreateTemplateFieldset',
r2a(withReactQuery(TemplateFieldset), ['onChange', 'value', 'onChangeFile'])
).name; ).name;

View File

@ -3,12 +3,26 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
import { CreateView } from '@/react/edge/templates/custom-templates/CreateView';
import { EditView } from '@/react/edge/templates/custom-templates/EditView';
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView'; import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
export const templatesModule = angular export const templatesModule = angular
.module('portainer.app.react.components.templates', []) .module('portainer.app.react.components.templates', [])
.component( .component(
'edgeAppTemplatesView', 'edgeAppTemplatesView',
r2a(withCurrentUser(withUIRouter(AppTemplatesView)), []) r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
)
.component(
'edgeCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(ListView)), [])
)
.component(
'edgeCreateCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(CreateView)), [])
)
.component(
'edgeEditCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(EditView)), [])
).name; ).name;

View File

@ -1,11 +0,0 @@
import angular from 'angular';
angular.module('portainer.edge').factory('EdgeTemplates', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_TEMPLATES) {
return $resource(
API_ENDPOINT_EDGE_TEMPLATES,
{},
{
query: { method: 'GET', isArray: true },
}
);
});

View File

@ -1,23 +0,0 @@
import angular from 'angular';
class EdgeTemplateService {
/* @ngInject */
constructor(EdgeTemplates) {
this.EdgeTemplates = EdgeTemplates;
}
edgeTemplates() {
return this.EdgeTemplates.query().$promise;
}
async edgeTemplate(template) {
const response = await fetch(template.stackFile);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.text();
}
}
angular.module('portainer.edge').service('EdgeTemplateService', EdgeTemplateService);

View File

@ -5,11 +5,14 @@ import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { EnvironmentType } from '@/react/portainer/environments/types'; import { EnvironmentType } from '@/react/portainer/environments/types';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { getCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { notifyError } from '@/portainer/services/notifications';
import { getCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
export default class CreateEdgeStackViewController { export default class CreateEdgeStackViewController {
/* @ngInject */ /* @ngInject */
constructor($state, $window, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async, $scope) { constructor($state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope) {
Object.assign(this, { $state, $window, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async, $scope }); Object.assign(this, { $state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope });
this.formValues = { this.formValues = {
Name: '', Name: '',
@ -41,6 +44,8 @@ export default class CreateEdgeStackViewController {
hasKubeEndpoint: false, hasKubeEndpoint: false,
endpointTypes: [], endpointTypes: [],
baseWebhookUrl: baseEdgeStackWebhookUrl(), baseWebhookUrl: baseEdgeStackWebhookUrl(),
isEdit: false,
selectedTemplate: null,
}; };
this.edgeGroups = null; this.edgeGroups = null;
@ -57,6 +62,16 @@ export default class CreateEdgeStackViewController {
this.hasType = this.hasType.bind(this); this.hasType = this.hasType.bind(this);
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
this.onEnvVarChange = this.onEnvVarChange.bind(this); this.onEnvVarChange = this.onEnvVarChange.bind(this);
this.onChangeTemplate = this.onChangeTemplate.bind(this);
}
/**
* @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate} template
*/
onChangeTemplate(template) {
return this.$scope.$evalAsync(() => {
this.state.selectedTemplate = template;
});
} }
onEnvVarChange(envVars) { onEnvVarChange(envVars) {
@ -70,7 +85,7 @@ export default class CreateEdgeStackViewController {
const metadata = { type: methodLabel(this.state.Method), format }; const metadata = { type: methodLabel(this.state.Method), format };
if (metadata.type === 'template') { if (metadata.type === 'template') {
metadata.templateName = this.selectedTemplate.title; metadata.templateName = this.state.selectedTemplate && this.state.selectedTemplate.title;
} }
return { metadata }; return { metadata };
@ -95,6 +110,18 @@ export default class CreateEdgeStackViewController {
} }
} }
async preSelectTemplate(templateId) {
try {
this.state.Method = 'template';
const template = await getCustomTemplate(templateId);
this.onChangeTemplate(template);
const fileContent = await getCustomTemplateFile({ id: templateId, git: !!template.GitConfig });
this.formValues.StackFileContent = fileContent;
} catch (e) {
notifyError('Failed loading template', e);
}
}
async $onInit() { async $onInit() {
try { try {
this.edgeGroups = await this.EdgeGroupService.groups(); this.edgeGroups = await this.EdgeGroupService.groups();
@ -102,6 +129,11 @@ export default class CreateEdgeStackViewController {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
} }
const templateId = this.$state.params.templateId;
if (templateId) {
this.preSelectTemplate(templateId);
}
this.$window.onbeforeunload = () => { this.$window.onbeforeunload = () => {
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) { if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
return ''; return '';

View File

@ -57,6 +57,8 @@
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose" ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
form-values="$ctrl.formValues" form-values="$ctrl.formValues"
state="$ctrl.state" state="$ctrl.state"
template="$ctrl.state.selectedTemplate"
on-change-template="($ctrl.onChangeTemplate)"
></edge-stacks-docker-compose-form> ></edge-stacks-docker-compose-form>
<edge-stacks-kube-manifest-form <edge-stacks-kube-manifest-form

View File

@ -1,18 +1,14 @@
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 {
/* @ngInject */ /* @ngInject */
constructor($async, EdgeTemplateService, Notifications) { constructor($async, Notifications) {
Object.assign(this, { $async, EdgeTemplateService, Notifications }); Object.assign(this, { $async, Notifications });
this.methodOptions = [editor, upload, git, edgeStackTemplate]; this.methodOptions = [editor, upload, git, edgeStackTemplate];
this.selectedTemplate = null;
this.onChangeFileContent = this.onChangeFileContent.bind(this); this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onChangeFile = this.onChangeFile.bind(this); this.onChangeFile = this.onChangeFile.bind(this);
this.onChangeTemplate = this.onChangeTemplate.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this); this.onChangeFormValues = this.onChangeFormValues.bind(this);
} }
@ -29,24 +25,13 @@ class DockerComposeFormController {
onChangeMethod(method) { onChangeMethod(method) {
this.state.Method = method; this.state.Method = method;
this.formValues.StackFileContent = ''; this.formValues.StackFileContent = '';
this.selectedTemplate = null;
}
onChangeTemplate(template) {
return this.$async(async () => {
this.formValues.StackFileContent = '';
try {
const fileContent = await fetchFilePreview(template.id);
this.formValues.StackFileContent = fileContent;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
}
});
} }
onChangeFileContent(value) { onChangeFileContent(value) {
return this.$async(async () => {
this.formValues.StackFileContent = value; this.formValues.StackFileContent = value;
this.state.isEditorDirty = true; this.state.isEditorDirty = true;
});
} }
onChangeFile(value) { onChangeFile(value) {
@ -54,17 +39,6 @@ class DockerComposeFormController {
this.formValues.StackFile = value; this.formValues.StackFile = value;
}); });
} }
async $onInit() {
return this.$async(async () => {
try {
const templates = await this.EdgeTemplateService.edgeTemplates();
this.templates = templates.map((template) => ({ ...template, label: `${template.title} - ${template.description}` }));
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Templates');
}
});
}
} }
export default DockerComposeFormController; export default DockerComposeFormController;

View File

@ -1,14 +1,25 @@
<div class="col-sm-12 form-section-title"> Build method </div> <div class="col-sm-12 form-section-title"> Build method </div>
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector> <box-selector slim="true" radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<!-- template -->
<div ng-if="$ctrl.state.Method === 'template'">
<edge-stack-create-template-fieldset
value="$ctrl.template"
on-change="($ctrl.onChangeTemplate)"
on-change-file="($ctrl.onChangeFileContent)"
></edge-stack-create-template-fieldset>
</div>
<!-- !template -->
<web-editor-form <web-editor-form
ng-if="$ctrl.state.Method === 'editor'" ng-if="$ctrl.state.Method === 'editor' || ($ctrl.state.Method === 'template' && $ctrl.template)"
identifier="stack-creation-editor" identifier="stack-creation-editor"
value="$ctrl.formValues.StackFileContent" value="$ctrl.formValues.StackFileContent"
on-change="($ctrl.onChangeFileContent)" on-change="($ctrl.onChangeFileContent)"
ng-required="true" ng-required="true"
yml="true" yml="true"
placeholder="Define or paste the content of your docker compose file here" placeholder="Define or paste the content of your docker compose file here"
read-only="$ctrl.state.Method === 'template' && $ctrl.template.GitConfig"
> >
<editor-description> <editor-description>
You can get more information about Compose file format in the You can get more information about Compose file format in the
@ -21,51 +32,12 @@
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description> <file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
</file-upload-form> </file-upload-form>
<git-form <div ng-if="$ctrl.state.Method == 'repository'">
ng-if="$ctrl.state.Method === 'repository'" <git-form
value="$ctrl.formValues" value="$ctrl.formValues"
on-change="($ctrl.onChangeFormValues)" on-change="($ctrl.onChangeFormValues)"
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}" base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
webhook-id="{{ $ctrl.state.webhookId }}" webhook-id="{{ $ctrl.state.webhookId }}"
docs-links docs-links
></git-form> ></git-form>
<!-- template -->
<div ng-if="$ctrl.state.Method === 'template'">
<div class="form-group">
<label for="stack_template" class="col-sm-1 control-label text-left"> Template </label>
<div class="col-sm-11">
<select
class="form-control"
ng-model="$ctrl.selectedTemplate"
ng-options="template as template.label for template in $ctrl.templates"
ng-change="$ctrl.onChangeTemplate($ctrl.selectedTemplate)"
>
<option value="" label="Select an Edge stack template" disabled selected="selected"> </option>
</select>
</div>
</div>
<!-- description -->
<div ng-if="$ctrl.selectedTemplate.note">
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="$ctrl.selectedTemplate.note"></div>
</div>
</div>
</div>
<!-- !description -->
<web-editor-form
ng-if="$ctrl.selectedTemplate && $ctrl.formValues.StackFileContent"
identifier="template-content-editor"
value="$ctrl.formValues.StackFileContent"
on-change="($ctrl.onChangeFileContent)"
yml="true"
placeholder="Define or paste the content of your docker compose file here"
ng-required="true"
>
</web-editor-form>
<!-- !template -->
</div> </div>

View File

@ -7,5 +7,7 @@ export const edgeStacksDockerComposeForm = {
bindings: { bindings: {
formValues: '=', formValues: '=',
state: '=', state: '=',
template: '<',
onChangeTemplate: '<',
}, },
}; };

View File

@ -1,6 +1,5 @@
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
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 { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
@ -13,7 +12,7 @@ class KubeCreateCustomTemplateViewController {
this.methodOptions = [editor, upload, git]; this.methodOptions = [editor, upload, git];
this.templates = null; this.templates = null;
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.state = { this.state = {
method: 'editor', method: 'editor',
@ -83,7 +82,7 @@ class KubeCreateCustomTemplateViewController {
return; return;
} }
const variables = getTemplateVariables(templateStr); const [variables] = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;

View File

@ -24,8 +24,8 @@ export default class KubeCustomTemplatesViewController {
this.selectTemplate = this.selectTemplate.bind(this); this.selectTemplate = this.selectTemplate.bind(this);
} }
selectTemplate(template) { selectTemplate(templateId) {
this.$state.go('kubernetes.deploy', { templateId: template }); this.$state.go('kubernetes.deploy', { templateId });
} }
isEditAllowed(template) { isEditAllowed(template) {
@ -36,7 +36,8 @@ export default class KubeCustomTemplatesViewController {
getTemplates() { getTemplates() {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.templates = await this.CustomTemplateService.customTemplates(3); const templates = await this.CustomTemplateService.customTemplates(3);
this.templates = templates.filter((t) => !t.EdgeTemplate);
} catch (err) { } catch (err) {
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates'); this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
} }

View File

@ -1,7 +1,6 @@
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getFilePreview } from '@/react/portainer/gitops/gitops.service'; import { getFilePreview } from '@/react/portainer/gitops/gitops.service';
import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
@ -11,7 +10,7 @@ class KubeEditCustomTemplateViewController {
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.formValues = { this.formValues = {
Variables: [], Variables: [],
@ -112,7 +111,7 @@ class KubeEditCustomTemplateViewController {
return; return;
} }
const variables = getTemplateVariables(templateStr); const [variables] = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;

View File

@ -4,14 +4,14 @@ import stripAnsi from 'strip-ansi';
import PortainerError from '@/portainer/error'; import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods'; import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
class KubernetesDeployController { class KubernetesDeployController {
/* @ngInject */ /* @ngInject */
@ -25,7 +25,7 @@ class KubernetesDeployController {
this.StackService = StackService; this.StackService = StackService;
this.CustomTemplateService = CustomTemplateService; this.CustomTemplateService = CustomTemplateService;
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.deployOptions = [{ ...kubernetes, value: KubernetesDeployManifestTypes.KUBERNETES }]; this.deployOptions = [{ ...kubernetes, value: KubernetesDeployManifestTypes.KUBERNETES }];
@ -72,7 +72,7 @@ class KubernetesDeployController {
RepositoryPassword: '', RepositoryPassword: '',
AdditionalFiles: [], AdditionalFiles: [],
ComposeFilePathInRepository: '', ComposeFilePathInRepository: '',
Variables: {}, Variables: [],
AutoUpdate: parseAutoUpdateResponse(), AutoUpdate: parseAutoUpdateResponse(),
TLSSkipVerify: false, TLSSkipVerify: false,
Name: '', Name: '',
@ -220,7 +220,7 @@ class KubernetesDeployController {
} }
if (template.Variables && template.Variables.length > 0) { if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); const variables = getVariablesFieldDefaultValues(template.Variables);
this.onChangeTemplateVariables(variables); this.onChangeTemplateVariables(variables);
} }
} catch (err) { } catch (err) {

View File

@ -26,6 +26,7 @@ export const ngModule = angular
'value', 'value',
'onChange', 'onChange',
'definitions', 'definitions',
'errors',
]) ])
) )
.component('customTemplatesVariablesField', VariablesFieldAngular) .component('customTemplatesVariablesField', VariablesFieldAngular)
@ -46,7 +47,6 @@ export const ngModule = angular
'selectedId', 'selectedId',
'disabledTypes', 'disabledTypes',
'fixedCategories', 'fixedCategories',
'hideDuplicate',
]) ])
) )
.component( .component(
@ -56,6 +56,7 @@ export const ngModule = angular
'onSelect', 'onSelect',
'templates', 'templates',
'selectedId', 'selectedId',
'templateLinkParams',
]) ])
) )
.component( .component(

View File

@ -101,3 +101,30 @@ export function isAxiosError<
>(error: unknown): error is AxiosError<ResponseType> { >(error: unknown): error is AxiosError<ResponseType> {
return axiosOrigin.isAxiosError(error); return axiosOrigin.isAxiosError(error);
} }
export function arrayToJson<T>(arr?: Array<T>) {
if (!arr) {
return '';
}
return JSON.stringify(arr);
}
export function json2formData(json: Record<string, unknown>) {
const formData = new FormData();
Object.entries(json).forEach(([key, value]) => {
if (typeof value === 'undefined' || value === null) {
return;
}
if (Array.isArray(value)) {
formData.append(key, arrayToJson(value));
return;
}
formData.append(key, value as string);
});
return formData;
}

View File

@ -1,11 +1,10 @@
import _ from 'lodash'; import _ from 'lodash';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
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'; import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
class CreateCustomTemplateViewController { class CreateCustomTemplateViewController {
/* @ngInject */ /* @ngInject */
@ -26,7 +25,7 @@ class CreateCustomTemplateViewController {
this.buildMethods = [editor, upload, git]; this.buildMethods = [editor, upload, git];
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.formValues = { this.formValues = {
Title: '', Title: '',
@ -207,7 +206,7 @@ class CreateCustomTemplateViewController {
return; return;
} }
const variables = getTemplateVariables(templateStr); const [variables] = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;

View File

@ -1,9 +1,9 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
class CustomTemplatesViewController { class CustomTemplatesViewController {
/* @ngInject */ /* @ngInject */
@ -34,7 +34,7 @@ class CustomTemplatesViewController {
this.StateManager = StateManager; this.StateManager = StateManager;
this.StackService = StackService; this.StackService = StackService;
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.DOCKER_STANDALONE = 'DOCKER_STANDALONE'; this.DOCKER_STANDALONE = 'DOCKER_STANDALONE';
this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE'; this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
@ -93,7 +93,8 @@ class CustomTemplatesViewController {
} }
async getTemplatesAsync() { async getTemplatesAsync() {
try { try {
this.templates = await this.CustomTemplateService.customTemplates([1, 2]); const templates = await this.CustomTemplateService.customTemplates([1, 2]);
this.templates = templates.filter((t) => !t.EdgeTemplate);
} catch (err) { } catch (err) {
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates'); this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
} }
@ -221,7 +222,7 @@ class CustomTemplatesViewController {
this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type); this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type);
if (template.Variables && template.Variables.length > 0) { if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); const variables = getVariablesFieldDefaultValues(template.Variables);
this.onChangeTemplateVariables(variables); this.onChangeTemplateVariables(variables);
} }
} }

View File

@ -4,8 +4,7 @@ import { ResourceControlViewModel } from '@/react/portainer/access-control/model
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
class EditCustomTemplateViewController { class EditCustomTemplateViewController {
@ -13,7 +12,7 @@ class EditCustomTemplateViewController {
constructor($async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { constructor($async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); Object.assign(this, { $async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
this.isTemplateVariablesEnabled = isBE; this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
this.formValues = { this.formValues = {
Variables: [], Variables: [],
@ -178,7 +177,7 @@ class EditCustomTemplateViewController {
return; return;
} }
const variables = getTemplateVariables(templateStr); const [variables] = getTemplateVariables(templateStr);
const isValid = !!variables; const isValid = !!variables;

View File

@ -4,12 +4,12 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy'; import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods'; import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
angular angular
.module('portainer.app') .module('portainer.app')
@ -28,13 +28,12 @@ angular
FormHelper, FormHelper,
StackHelper, StackHelper,
ContainerHelper, ContainerHelper,
CustomTemplateService,
ContainerService, ContainerService,
endpoint endpoint
) { ) {
$scope.onChangeTemplateId = onChangeTemplateId; $scope.onChangeTemplateId = onChangeTemplateId;
$scope.onChangeTemplateVariables = onChangeTemplateVariables; $scope.onChangeTemplateVariables = onChangeTemplateVariables;
$scope.isTemplateVariablesEnabled = isBE; $scope.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
$scope.buildAnalyticsProperties = buildAnalyticsProperties; $scope.buildAnalyticsProperties = buildAnalyticsProperties;
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK; $scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
$scope.buildMethods = [editor, upload, git, customTemplate]; $scope.buildMethods = [editor, upload, git, customTemplate];
@ -54,7 +53,7 @@ angular
ComposeFilePathInRepository: 'docker-compose.yml', ComposeFilePathInRepository: 'docker-compose.yml',
AccessControlData: new AccessControlFormData(), AccessControlData: new AccessControlFormData(),
EnableWebhook: false, EnableWebhook: false,
Variables: {}, Variables: [],
AutoUpdate: parseAutoUpdateResponse(), AutoUpdate: parseAutoUpdateResponse(),
TLSSkipVerify: false, TLSSkipVerify: false,
}; };
@ -313,7 +312,7 @@ angular
} }
if (template.Variables && template.Variables.length > 0) { if (template.Variables && template.Variables.length > 0) {
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); const variables = getVariablesFieldDefaultValues(template.Variables);
onChangeTemplateVariables(variables); onChangeTemplateVariables(variables);
} }
} catch (err) { } catch (err) {

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 text-left', 'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 no-link text-left',
{ {
'blocklist-item--selected': isSelected, 'blocklist-item--selected': isSelected,
} }

View File

@ -1,4 +1,5 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren, useEffect, useMemo } from 'react';
import { useTransitionHook } from '@uirouter/react';
import { BROWSER_OS_PLATFORM } from '@/react/constants'; import { BROWSER_OS_PLATFORM } from '@/react/constants';
@ -6,6 +7,10 @@ import { CodeEditor } from '@@/CodeEditor';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { FormSectionTitle } from './form-components/FormSectionTitle'; import { FormSectionTitle } from './form-components/FormSectionTitle';
import { FormError } from './form-components/FormError';
import { confirm } from './modals/confirm';
import { ModalType } from './modals';
import { buildConfirmButton } from './modals/utils';
const otherEditorConfig = { const otherEditorConfig = {
tooltip: ( tooltip: (
@ -91,6 +96,8 @@ export function WebEditorForm({
</div> </div>
)} )}
{error && <FormError>{error}</FormError>}
<div className="form-group"> <div className="form-group">
<div className="col-sm-12 col-lg-12"> <div className="col-sm-12 col-lg-12">
<CodeEditor <CodeEditor
@ -104,11 +111,59 @@ export function WebEditorForm({
/> />
</div> </div>
</div> </div>
<div className="form-group">
<div className="col-sm-12 col-lg-12">{error}</div>
</div>
</div> </div>
</div> </div>
); );
} }
export function usePreventExit(
initialValue: string,
value: string,
check: boolean
) {
const isChanged = useMemo(
() => cleanText(initialValue) !== cleanText(value),
[initialValue, value]
);
const preventExit = check && isChanged;
// when navigating away from the page with unsaved changes, show a portainer prompt to confirm
useTransitionHook('onBefore', {}, async () => {
if (!preventExit) {
return true;
}
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You currently have unsaved changes in the text editor. Are you sure you want to leave?',
confirmButton: buildConfirmButton('Yes', 'danger'),
});
return confirmed;
});
// when reloading or exiting the page with unsaved changes, show a browser prompt to confirm
useEffect(() => {
function handler(event: BeforeUnloadEvent) {
if (!preventExit) {
return undefined;
}
event.preventDefault();
// eslint-disable-next-line no-param-reassign
event.returnValue = '';
return '';
}
// if the form is changed, then set the onbeforeunload
window.addEventListener('beforeunload', handler);
return () => {
window.removeEventListener('beforeunload', handler);
};
}, [preventExit]);
}
function cleanText(value: string) {
return value.replace(/(\r\n|\n|\r)/gm, '');
}

View File

@ -1,13 +1,16 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types';
import { LoadingButton } from '@@/buttons'; import { LoadingButton } from '@@/buttons';
interface Props { import { FormSection } from './FormSection';
interface Props extends AutomationTestingProps {
submitLabel: string; submitLabel: string;
loadingText: string; loadingText: string;
isLoading: boolean; isLoading: boolean;
isValid: boolean; isValid: boolean;
'data-cy'?: string;
} }
export function FormActions({ export function FormActions({
@ -19,6 +22,7 @@ export function FormActions({
'data-cy': dataCy, 'data-cy': dataCy,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<FormSection title="Actions">
<div className="form-group"> <div className="form-group">
<div className="col-sm-12"> <div className="col-sm-12">
<LoadingButton <LoadingButton
@ -34,5 +38,6 @@ export function FormActions({
{children} {children}
</div> </div>
</div> </div>
</FormSection>
); );
} }

View File

@ -0,0 +1,142 @@
import { useState } from 'react';
import sanitize from 'sanitize-html';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplateFileMutation } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import {
CustomTemplatesVariablesField,
VariablesFieldValue,
getVariablesFieldDefaultValues,
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { FormControl } from '@@/form-components/FormControl';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
export function TemplateFieldset({
value: selectedTemplate,
onChange,
onChangeFile,
}: {
value: CustomTemplate | undefined;
onChange: (value?: CustomTemplate) => void;
onChangeFile: (value: string) => void;
}) {
const fetchFileMutation = useCustomTemplateFileMutation();
const [templateFile, setTemplateFile] = useState('');
const templatesQuery = useCustomTemplates({
select: (templates) =>
templates.filter((template) => template.EdgeTemplate),
});
const [variableValues, setVariableValues] = useState<VariablesFieldValue>([]);
return (
<>
<TemplateSelector
value={selectedTemplate?.Id}
onChange={handleChangeTemplate}
/>
{selectedTemplate && (
<>
{selectedTemplate.Note && (
<div>
<div className="col-sm-12 form-section-title"> Information </div>
<div className="form-group">
<div className="col-sm-12">
<div
className="template-note"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: sanitize(selectedTemplate.Note),
}}
/>
</div>
</div>
</div>
)}
<CustomTemplatesVariablesField
onChange={(value) => {
setVariableValues(value);
onChangeFile(
renderTemplate(templateFile, value, selectedTemplate.Variables)
);
}}
value={variableValues}
definitions={selectedTemplate.Variables}
/>
</>
)}
</>
);
function handleChangeTemplate(templateId: CustomTemplate['Id'] | undefined) {
const selectedTemplate = templatesQuery.data?.find(
(template) => template.Id === templateId
);
if (!selectedTemplate) {
setVariableValues([]);
onChange(undefined);
return;
}
fetchFileMutation.mutate(
{ id: selectedTemplate.Id, git: !!selectedTemplate.GitConfig },
{
onSuccess: (data) => {
setTemplateFile(data);
onChangeFile(
renderTemplate(
data,
getVariablesFieldDefaultValues(selectedTemplate.Variables),
selectedTemplate.Variables
)
);
},
}
);
setVariableValues(
selectedTemplate
? getVariablesFieldDefaultValues(selectedTemplate.Variables)
: []
);
onChange(selectedTemplate);
}
}
function TemplateSelector({
value,
onChange,
}: {
value: CustomTemplate['Id'] | undefined;
onChange: (value: CustomTemplate['Id'] | undefined) => void;
}) {
const templatesQuery = useCustomTemplates({
select: (templates) =>
templates.filter((template) => template.EdgeTemplate),
});
if (!templatesQuery.data) {
return null;
}
return (
<FormControl label="Template" inputId="stack_template">
<PortainerSelect
placeholder="Select an Edge stack template"
value={value}
onChange={handleChange}
options={templatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
value: template.Id,
}))}
/>
</FormControl>
);
function handleChange(value: CustomTemplate['Id']) {
onChange(value);
}
}

View File

@ -34,7 +34,6 @@ export function AppTemplatesView() {
onSelect={(template) => setSelectedTemplateId(template.Id)} onSelect={(template) => setSelectedTemplateId(template.Id)}
disabledTypes={[TemplateType.Container]} disabledTypes={[TemplateType.Container]}
fixedCategories={['edge']} fixedCategories={['edge']}
hideDuplicate
/> />
</> </>
); );

View File

@ -0,0 +1,107 @@
import { Formik } from 'formik';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { StackType } from '@/react/common/stacks/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation';
import { Platform } from '@/react/portainer/templates/types';
import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { editor } from '@@/BoxSelector/common-options/build-methods';
import { InnerForm } from './InnerForm';
import { FormValues } from './types';
import { useValidation } from './useValidation';
export function CreateTemplateForm() {
const router = useRouter();
const mutation = useCreateTemplateMutation();
const validation = useValidation();
const { appTemplateId, type } = useParams();
const fileContentQuery = useFetchTemplateFile(appTemplateId);
if (fileContentQuery.isLoading) {
return null;
}
const initialValues: FormValues = {
Title: '',
FileContent: fileContentQuery.data ?? '',
Type: type,
File: undefined,
Method: editor.value,
Description: '',
Note: '',
Logo: '',
Platform: Platform.LINUX,
Variables: [],
Git: {
RepositoryURL: '',
RepositoryReferenceName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
ComposeFilePathInRepository: 'docker-compose.yml',
AdditionalFiles: [],
RepositoryURLValid: true,
TLSSkipVerify: false,
},
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
<InnerForm isLoading={mutation.isLoading} />
</Formik>
);
function handleSubmit(values: FormValues) {
mutation.mutate(
{ ...values, EdgeTemplate: true },
{
onSuccess() {
notifySuccess('Success', 'Template created');
router.stateService.go('^');
},
}
);
}
}
function useParams() {
const {
params: { type = StackType.DockerCompose, appTemplateId },
} = useCurrentStateAndParams();
return {
type: getStackType(type),
appTemplateId: getTemplateId(appTemplateId),
};
function getStackType(type: string): StackType {
const typeNum = parseInt(type, 10);
if (
[
StackType.DockerSwarm,
StackType.DockerCompose,
StackType.Kubernetes,
].includes(typeNum)
) {
return typeNum;
}
return StackType.DockerCompose;
}
function getTemplateId(appTemplateId: string): number | undefined {
const id = parseInt(appTemplateId, 10);
return Number.isNaN(id) ? undefined : id;
}
}

View File

@ -0,0 +1,28 @@
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { CreateTemplateForm } from './CreateTemplateForm';
export function CreateView() {
return (
<div>
<PageHeader
title="Create Custom template"
breadcrumbs={[
{ label: 'Custom Templates', link: '^' },
'Create Custom template',
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<CreateTemplateForm />
</Widget.Body>
</Widget>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,172 @@
import { Form, useFormikContext } from 'formik';
import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields';
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import {
getTemplateVariables,
intersectVariables,
isTemplateVariablesEnabled,
} from '@/react/portainer/custom-templates/components/utils';
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
import { BoxSelector } from '@@/BoxSelector';
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
import { FileUploadForm } from '@@/form-components/FileUpload';
import { FormActions } from '@@/form-components/FormActions';
import { FormSection } from '@@/form-components/FormSection';
import {
editor,
upload,
git,
} from '@@/BoxSelector/common-options/build-methods';
import { FormValues, Method, buildMethods } from './types';
export function InnerForm({ isLoading }: { isLoading: boolean }) {
const {
values,
initialValues,
setFieldValue,
errors,
isValid,
setFieldError,
setValues,
isSubmitting,
} = useFormikContext<FormValues>();
usePreventExit(
initialValues.FileContent,
values.FileContent,
values.Method === editor.value && !isSubmitting
);
return (
<Form className="form-horizontal">
<CommonFields
values={values}
onChange={(newValues) =>
setValues((values) => ({ ...values, ...newValues }))
}
errors={errors}
/>
<PlatformField
value={values.Platform}
onChange={(value) => setFieldValue('Platform', value)}
/>
<TemplateTypeSelector
value={values.Type}
onChange={(value) => setFieldValue('Type', value)}
/>
<FormSection title="Build method">
<BoxSelector
slim
options={buildMethods}
value={values.Method}
onChange={handleChangeMethod}
radioName="buildMethod"
/>
</FormSection>
{values.Method === editor.value && (
<WebEditorForm
id="custom-template-creation-editor"
value={values.FileContent}
onChange={handleChangeFileContent}
yaml
placeholder="Define or paste the content of your docker compose file here"
error={errors.FileContent}
>
<p>
You can get more information about Compose file format in the{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</p>
</WebEditorForm>
)}
{values.Method === upload.value && (
<FileUploadForm
description="You can upload a Compose file from your computer."
value={values.File}
onChange={(value) => setFieldValue('File', value)}
required
/>
)}
{values.Method === git.value && (
<GitForm
value={values.Git}
onChange={(newValues) =>
setValues((values) => ({
...values,
Git: { ...values.Git, ...newValues },
}))
}
errors={errors.Git}
/>
)}
{isTemplateVariablesEnabled && (
<CustomTemplatesVariablesDefinitionField
value={values.Variables}
onChange={(values) => setFieldValue('Variables', values)}
isVariablesNamesFromParent={values.Method === editor.value}
errors={errors.Variables}
/>
)}
<FormActions
isLoading={isLoading}
isValid={isValid}
loadingText="Creating custom template..."
submitLabel="Create custom template"
/>
</Form>
);
function handleChangeFileContent(value: string) {
setFieldValue(
'FileContent',
value,
isTemplateVariablesEnabled ? !value : true
);
parseTemplate(value);
}
function parseTemplate(value: string) {
if (!isTemplateVariablesEnabled || value === '') {
setFieldValue('Variables', []);
return;
}
const [variables, validationError] = getTemplateVariables(value);
const isValid = !!variables;
setFieldError(
'FileContent',
validationError ? `Template invalid: ${validationError}` : undefined
);
if (isValid) {
setFieldValue(
'Variables',
intersectVariables(values.Variables, variables)
);
}
}
function handleChangeMethod(method: Method) {
setFieldValue('FileContent', '');
setFieldValue('Variables', []);
setFieldValue('Method', method);
}
}

View File

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

View File

@ -0,0 +1,25 @@
import { StackType } from '@/react/common/stacks/types';
import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields';
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { Platform } from '@/react/portainer/templates/types';
import { GitFormModel } from '@/react/portainer/gitops/types';
import {
editor,
upload,
git,
} from '@@/BoxSelector/common-options/build-methods';
export const buildMethods = [editor, upload, git] as const;
export type Method = (typeof buildMethods)[number]['value'];
export interface FormValues extends CommonFieldsValues {
Platform: Platform;
Type: StackType;
Method: Method;
FileContent: string;
File: File | undefined;
Git: GitFormModel;
Variables: DefinitionFieldValues;
}

View File

@ -0,0 +1,59 @@
import { mixed, number, object, string } from 'yup';
import { useMemo } from 'react';
import { StackType } from '@/react/common/stacks/types';
import { validation as commonFieldsValidation } from '@/react/portainer/custom-templates/components/CommonFields';
import { Platform } from '@/react/portainer/templates/types';
import { variablesValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { file } from '@@/form-components/yup-file-validation';
import {
editor,
git,
upload,
} from '@@/BoxSelector/common-options/build-methods';
import { buildMethods } from './types';
export function useValidation() {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates();
return useMemo(
() =>
object({
Platform: number()
.oneOf([Platform.LINUX, Platform.WINDOWS])
.default(Platform.LINUX),
Type: number()
.oneOf([
StackType.DockerCompose,
StackType.DockerSwarm,
StackType.Kubernetes,
])
.default(StackType.DockerCompose),
Method: string().oneOf(buildMethods.map((m) => m.value)),
FileContent: string().when('Method', {
is: editor.value,
then: (schema) => schema.required('Template is required.'),
}),
File: file().when('Method', {
is: upload.value,
then: (schema) => schema.required(),
}),
Git: mixed().when('Method', {
is: git.value,
then: () => buildGitValidationSchema(gitCredentialsQuery.data || []),
}),
Variables: variablesValidation(),
}).concat(
commonFieldsValidation({ templates: customTemplatesQuery.data })
),
[customTemplatesQuery.data, gitCredentialsQuery.data]
);
}

View File

@ -0,0 +1,99 @@
import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import { useUpdateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation';
import {
getTemplateVariables,
intersectVariables,
isTemplateVariablesEnabled,
} from '@/react/portainer/custom-templates/components/utils';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { InnerForm } from './InnerForm';
import { FormValues } from './types';
import { useValidation } from './useValidation';
export function EditTemplateForm({ template }: { template: CustomTemplate }) {
const mutation = useUpdateTemplateMutation();
const router = useRouter();
const isGit = !!template.GitConfig;
const validation = useValidation(template.Id, isGit);
const fileQuery = useCustomTemplateFile(template.Id, isGit);
if (fileQuery.isLoading) {
return null;
}
const initialValues: FormValues = {
Title: template.Title,
Type: template.Type,
Description: template.Description,
Note: template.Note,
Logo: template.Logo,
Platform: template.Platform,
Variables: parseTemplate(fileQuery.data || ''),
FileContent: fileQuery.data || '',
Git: template.GitConfig ? toGitFormModel(template.GitConfig) : undefined,
};
return (
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
<InnerForm
isLoading={mutation.isLoading}
isEditorReadonly={isGit}
gitFileContent={isGit ? fileQuery.data : ''}
refreshGitFile={fileQuery.refetch}
gitFileError={
fileQuery.error instanceof Error ? fileQuery.error.message : ''
}
/>
</Formik>
);
function handleSubmit(values: FormValues) {
mutation.mutate(
{
id: template.Id,
EdgeTemplate: template.EdgeTemplate,
Description: values.Description,
Title: values.Title,
Type: values.Type,
Logo: values.Logo,
FileContent: values.FileContent,
Note: values.Note,
Platform: values.Platform,
Variables: values.Variables,
...values.Git,
},
{
onSuccess() {
notifySuccess('Success', 'Template updated successfully');
router.stateService.go('^');
},
}
);
}
function parseTemplate(templateContent: string) {
if (!isTemplateVariablesEnabled) {
return template.Variables;
}
const [variables] = getTemplateVariables(templateContent);
if (!variables) {
return template.Variables;
}
return intersectVariables(template.Variables, variables);
}
}

View File

@ -0,0 +1,49 @@
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useEffect } from 'react';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { notifyError } from '@/portainer/services/notifications';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { EditTemplateForm } from './EditTemplateForm';
export function EditView() {
const router = useRouter();
const {
params: { id },
} = useCurrentStateAndParams();
const customTemplateQuery = useCustomTemplate(id);
useEffect(() => {
if (customTemplateQuery.data && !customTemplateQuery.data.EdgeTemplate) {
notifyError('Error', new Error('Trying to load non edge template'));
router.stateService.go('^');
}
}, [customTemplateQuery.data, router.stateService]);
if (!customTemplateQuery.data) {
return null;
}
const template = customTemplateQuery.data;
return (
<>
<PageHeader
title="Edit Custom Template"
breadcrumbs={[{ label: 'Custom templates', link: '^' }, template.Title]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<EditTemplateForm template={template} />
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,170 @@
import { Form, useFormikContext } from 'formik';
import { RefreshCw } from 'lucide-react';
import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields';
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import {
getTemplateVariables,
intersectVariables,
isTemplateVariablesEnabled,
} from '@/react/portainer/custom-templates/components/utils';
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { FormError } from '@@/form-components/FormError';
import { FormValues } from './types';
export function InnerForm({
isLoading,
isEditorReadonly,
gitFileContent,
gitFileError,
refreshGitFile,
}: {
isLoading: boolean;
isEditorReadonly: boolean;
gitFileContent?: string;
gitFileError?: string;
refreshGitFile: () => void;
}) {
const {
values,
initialValues,
setFieldValue,
errors,
isValid,
setFieldError,
isSubmitting,
dirty,
setValues,
} = useFormikContext<FormValues>();
usePreventExit(
initialValues.FileContent,
values.FileContent,
!isEditorReadonly && !isSubmitting
);
return (
<Form className="form-horizontal">
<CommonFields
values={values}
onChange={(newValues) =>
setValues((values) => ({ ...values, ...newValues }))
}
errors={errors}
/>
<PlatformField
value={values.Platform}
onChange={(value) => setFieldValue('Platform', value)}
/>
<TemplateTypeSelector
value={values.Type}
onChange={(value) => setFieldValue('Type', value)}
/>
<WebEditorForm
id="edit-custom-template-editor"
value={gitFileContent || values.FileContent}
onChange={handleChangeFileContent}
yaml
placeholder="Define or paste the content of your docker compose file here"
error={errors.FileContent}
readonly={isEditorReadonly}
>
<p>
You can get more information about Compose file format in the{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</p>
</WebEditorForm>
{values.Git && (
<>
<GitForm
value={values.Git}
onChange={(newValues) =>
setValues((values) => ({
...values,
// set ! for values.Git because this callback will only be called when it's defined (see L94)
Git: { ...values.Git!, ...newValues },
}))
}
errors={typeof errors.Git === 'object' ? errors.Git : undefined}
/>
<div className="form-group">
<div className="col-sm-12">
<Button color="light" icon={RefreshCw} onClick={refreshGitFile}>
Reload custom template
</Button>
</div>
{gitFileError && (
<div className="col-sm-12">
<FormError>
Custom template could not be loaded, {gitFileError}.
</FormError>
</div>
)}
</div>
</>
)}
{isTemplateVariablesEnabled && (
<CustomTemplatesVariablesDefinitionField
value={values.Variables}
onChange={(values) => setFieldValue('Variables', values)}
isVariablesNamesFromParent={!isEditorReadonly}
errors={errors.Variables}
/>
)}
<FormActions
isLoading={isLoading}
isValid={isValid && dirty}
loadingText="Updating custom template..."
submitLabel="Update custom template"
/>
</Form>
);
function handleChangeFileContent(value: string) {
setFieldValue(
'FileContent',
value,
isTemplateVariablesEnabled ? !value : true
);
parseTemplate(value);
}
function parseTemplate(value: string) {
if (!isTemplateVariablesEnabled || value === '') {
setFieldValue('Variables', []);
return;
}
const [variables, validationError] = getTemplateVariables(value);
setFieldError(
'FileContent',
validationError ? `Template invalid: ${validationError}` : undefined
);
if (variables) {
setFieldValue(
'Variables',
intersectVariables(values.Variables, variables)
);
}
}
}

View File

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

View File

@ -0,0 +1,13 @@
import { StackType } from '@/react/common/stacks/types';
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { Platform } from '@/react/portainer/templates/types';
import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields';
import { GitFormModel } from '@/react/portainer/gitops/types';
export interface FormValues extends CommonFieldsValues {
Platform: Platform;
Type: StackType;
FileContent: string;
Git?: GitFormModel;
Variables: DefinitionFieldValues;
}

View File

@ -0,0 +1,56 @@
import { mixed, number, object, string } from 'yup';
import { useMemo } from 'react';
import { StackType } from '@/react/common/stacks/types';
import { validation as commonFieldsValidation } from '@/react/portainer/custom-templates/components/CommonFields';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { variablesValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { Platform } from '@/react/portainer/templates/types';
export function useValidation(
currentTemplateId: CustomTemplate['Id'],
isGit: boolean
) {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates();
return useMemo(
() =>
object({
Platform: number()
.oneOf([Platform.LINUX, Platform.WINDOWS])
.default(Platform.LINUX),
Type: number()
.oneOf([
StackType.DockerCompose,
StackType.DockerSwarm,
StackType.Kubernetes,
])
.default(StackType.DockerCompose),
FileContent: isGit
? string().default('')
: string().required('Template is required.'),
Git: isGit
? buildGitValidationSchema(gitCredentialsQuery.data || [])
: mixed(),
Variables: variablesValidation(),
}).concat(
commonFieldsValidation({
templates: customTemplatesQuery.data,
currentTemplateId,
})
),
[
currentTemplateId,
customTemplatesQuery.data,
gitCredentialsQuery.data,
isGit,
]
);
}

View File

@ -0,0 +1,46 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { useDeleteTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useDeleteTemplateMutation';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { CustomTemplatesList } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesList';
import { PageHeader } from '@@/PageHeader';
import { confirmDelete } from '@@/modals/confirm';
export function ListView() {
const templatesQuery = useCustomTemplates({
select(templates) {
return templates.filter((t) => t.EdgeTemplate);
},
});
const deleteMutation = useDeleteTemplateMutation();
return (
<>
<PageHeader title="Custom Templates" breadcrumbs="Custom Templates" />
<CustomTemplatesList
templates={templatesQuery.data}
onDelete={handleDelete}
templateLinkParams={(template) => ({
to: 'edge.stacks.new',
params: { templateId: template.Id },
})}
/>
</>
);
async function handleDelete(templateId: CustomTemplate['Id']) {
if (
!(await confirmDelete('Are you sure you want to delete this template?'))
) {
return;
}
deleteMutation.mutate(templateId, {
onSuccess: () => {
notifySuccess('Success', 'Template deleted');
},
});
}
}

View File

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

View File

@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { success as notifySuccess } from '@/portainer/services/notifications'; import { success as notifySuccess } from '@/portainer/services/notifications';
import { UserId } from '@/portainer/users/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { import {
CreateGitCredentialPayload, CreateGitCredentialPayload,
@ -112,9 +115,12 @@ export function useDeleteGitCredentialMutation() {
}); });
} }
export function useGitCredentials(userId: number) { export function useGitCredentials(
userId: UserId,
{ enabled }: { enabled?: boolean } = {}
) {
return useQuery('gitcredentials', () => getGitCredentials(userId), { return useQuery('gitcredentials', () => getGitCredentials(userId), {
staleTime: 20, enabled: isBE && enabled,
meta: { meta: {
error: { error: {
title: 'Failure', title: 'Failure',

View File

@ -4,7 +4,9 @@ import { FormikErrors } from 'formik';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
interface Values { import { CustomTemplate } from '../../templates/custom-templates/types';
export interface Values {
Title: string; Title: string;
Description: string; Description: string;
Note: string; Note: string;
@ -87,11 +89,26 @@ export function CommonFields({
} }
export function validation({ export function validation({
currentTemplateId,
templates = [],
title, title,
}: { }: {
currentTemplateId?: CustomTemplate['Id'];
templates?: Array<CustomTemplate>;
title?: { pattern: string; error: string }; title?: { pattern: string; error: string };
} = {}): SchemaOf<Values> { } = {}): SchemaOf<Values> {
let titleSchema = string().required('Title is required.'); let titleSchema = string()
.required('Title is required.')
.test(
'is-unique',
'Title must be unique',
(value) =>
!value ||
!templates.some(
(template) =>
template.Title === value && template.Id !== currentTemplateId
)
);
if (title?.pattern) { if (title?.pattern) {
const pattern = new RegExp(title.pattern); const pattern = new RegExp(title.pattern);
titleSchema = titleSchema.matches(pattern, title.error); titleSchema = titleSchema.matches(pattern, title.error);

View File

@ -1,9 +1,9 @@
import { FormikErrors } from 'formik'; import { SchemaOf, array, object, string } from 'yup';
import { FormError } from '@@/form-components/FormError'; import { FormError } from '@@/form-components/FormError';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { InputList } from '@@/form-components/InputList'; import { InputList } from '@@/form-components/InputList';
import { ItemProps } from '@@/form-components/InputList/InputList'; import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList';
export interface VariableDefinition { export interface VariableDefinition {
name: string; name: string;
@ -12,10 +12,12 @@ export interface VariableDefinition {
description: string; description: string;
} }
export type Values = VariableDefinition[];
interface Props { interface Props {
value: VariableDefinition[]; value: Values;
onChange: (value: VariableDefinition[]) => void; onChange: (value: Values) => void;
errors?: FormikErrors<VariableDefinition>[]; errors?: ArrayError<Values>;
isVariablesNamesFromParent?: boolean; isVariablesNamesFromParent?: boolean;
} }
@ -107,3 +109,16 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
onChange({ ...item, [e.target.name]: e.target.value }); onChange({ ...item, [e.target.name]: e.target.value });
} }
} }
function itemValidation(): SchemaOf<VariableDefinition> {
return object().shape({
name: string().required('Name is required'),
label: string().required('Label is required'),
defaultValue: string().default(''),
description: string().default(''),
});
}
export function validation(): SchemaOf<Values> {
return array().of(itemValidation());
}

View File

@ -1,2 +1,8 @@
export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField'; export {
export type { VariableDefinition } from './CustomTemplatesVariablesDefinitionField'; CustomTemplatesVariablesDefinitionField,
validation as variablesValidation,
} from './CustomTemplatesVariablesDefinitionField';
export type {
VariableDefinition,
Values as DefinitionFieldValues,
} from './CustomTemplatesVariablesDefinitionField';

View File

@ -4,7 +4,7 @@ import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/C
import { import {
CustomTemplatesVariablesField, CustomTemplatesVariablesField,
Variables, Values,
} from './CustomTemplatesVariablesField'; } from './CustomTemplatesVariablesField';
export default { export default {
@ -34,10 +34,8 @@ const definitions: VariableDefinition[] = [
]; ];
function Template() { function Template() {
const [value, setValue] = useState<Variables>( const [value, setValue] = useState<Values>(
Object.fromEntries( definitions.map((def) => ({ key: def.name, value: def.defaultValue || '' }))
definitions.map((def) => [def.name, def.defaultValue || ''])
)
); );
return ( return (

View File

@ -1,18 +1,24 @@
import { array, object, string } from 'yup';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection/FormSection'; import { FormSection } from '@@/form-components/FormSection/FormSection';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { FormError } from '@@/form-components/FormError';
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
export type Variables = Record<string, string>; export type Values = Array<{ key: string; value?: string }>;
interface Props { interface Props {
value: Variables; errors?: ArrayError<Values>;
definitions?: VariableDefinition[]; value: Values;
onChange: (value: Variables) => void; definitions: VariableDefinition[] | undefined;
onChange: (value: Values) => void;
} }
export function CustomTemplatesVariablesField({ export function CustomTemplatesVariablesField({
errors,
value, value,
definitions, definitions,
onChange, onChange,
@ -23,32 +29,88 @@ export function CustomTemplatesVariablesField({
return ( return (
<FormSection title="Template Variables"> <FormSection title="Template Variables">
{definitions.map((def) => { {definitions.map((definition, index) => (
const inputId = `${def.name}-input`; <VariableFieldItem
const variable = value[def.name] || ''; key={definition.name}
return ( definition={definition}
<FormControl value={value.find((v) => v.key === definition.name)?.value || ''}
required={!def.defaultValue} error={getError(errors, index)}
label={def.label} onChange={(fieldValue) => {
key={def.name} onChange(
inputId={inputId} value.map((v) =>
tooltip={def.description} v.key === definition.name ? { ...v, value: fieldValue } : v
size="small" )
>
<Input
name={`variables.${def.name}`}
value={variable}
id={inputId}
onChange={(e) =>
onChange({
...value,
[def.name]: e.target.value,
})
}
/>
</FormControl>
); );
})} }}
/>
))}
{typeof errors === 'string' && <FormError>{errors}</FormError>}
</FormSection> </FormSection>
); );
} }
function VariableFieldItem({
definition,
value,
error,
onChange,
}: {
definition: VariableDefinition;
value: string;
error?: string;
onChange: (value: string) => void;
}) {
const inputId = `${definition.name}-input`;
return (
<FormControl
required={!definition.defaultValue}
label={definition.label}
key={definition.name}
inputId={inputId}
tooltip={definition.description}
size="small"
errors={error}
>
<Input
name={`variables.${definition.name}`}
value={value}
id={inputId}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
);
}
function getError(errors: ArrayError<Values> | undefined, index: number) {
if (!errors || typeof errors !== 'object') {
return undefined;
}
const error = errors[index];
if (!error) {
return undefined;
}
return typeof error === 'object' ? error.value : error;
}
export function validation(definitions: VariableDefinition[]) {
return array(
object({
key: string().default(''),
value: string().default(''),
}).test('required-if-no-default-value', 'This field is required', (obj) => {
const definition = definitions.find((d) => d.name === obj.key);
if (!definition) {
return true;
}
if (!definition.defaultValue && !obj.value) {
return false;
}
return true;
})
);
}

View File

@ -0,0 +1,10 @@
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField';
import { Values } from './CustomTemplatesVariablesField';
export function getDefaultValues(definitions: VariableDefinition[]): Values {
return definitions.map((v) => ({
key: v.name,
value: v.defaultValue,
}));
}

View File

@ -1 +1,7 @@
export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField'; export {
CustomTemplatesVariablesField,
type Values as VariablesFieldValue,
validation as variablesFieldValidation,
} from './CustomTemplatesVariablesField';
export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';

View File

@ -1,7 +1,7 @@
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/Input'; import { Select } from '@@/form-components/Input';
import { Platform } from '../types'; import { Platform } from '../../templates/types';
const platformOptions = [ const platformOptions = [
{ label: 'Linux', value: Platform.LINUX }, { label: 'Linux', value: Platform.LINUX },

View File

@ -1,34 +1,48 @@
import _ from 'lodash'; import _ from 'lodash';
import Mustache from 'mustache'; import Mustache from 'mustache';
import { isBE } from '../../feature-flags/feature-flags.service';
import { VariableDefinition } from './CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; import { VariableDefinition } from './CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
import { VariablesFieldValue } from './CustomTemplatesVariablesField';
export const isTemplateVariablesEnabled = isBE;
export function getTemplateVariables(templateStr: string) { export function getTemplateVariables(templateStr: string) {
const template = validateAndParse(templateStr); const [template, error] = validateAndParse(templateStr);
if (!template) { if (!template) {
return null; return [null, error] as const;
} }
return template return [
template
.filter(([type, value]) => type === 'name' && value) .filter(([type, value]) => type === 'name' && value)
.map(([, value]) => ({ .map(([, value]) => ({
name: value, name: value,
label: '', label: '',
defaultValue: '', defaultValue: '',
description: '', description: '',
})); })),
null,
] as const;
} }
type TemplateSpans = ReturnType<typeof Mustache.parse>;
function validateAndParse(templateStr: string) { function validateAndParse(
templateStr: string
): readonly [TemplateSpans, null] | readonly [null, string] {
if (!templateStr) { if (!templateStr) {
return []; return [[] as TemplateSpans, null] as const;
} }
try { try {
return Mustache.parse(templateStr); return [Mustache.parse(templateStr), null] as const;
} catch (e) { } catch (e) {
return null; if (!(e instanceof Error)) {
return [null, 'Parse error'] as const;
}
return [null, e.message] as const;
} }
} }
@ -51,22 +65,22 @@ export function intersectVariables(
export function renderTemplate( export function renderTemplate(
template: string, template: string,
variables: Record<string, string>, variables: VariablesFieldValue,
definitions: VariableDefinition[] definitions: VariableDefinition[]
) { ) {
const state = Object.fromEntries( const state = Object.fromEntries(
_.compact( _.compact(
Object.entries(variables).map(([name, value]) => { variables.map(({ key, value }) => {
if (value) { if (value) {
return [name, value]; return [key, value];
} }
const definition = definitions.find((def) => def.name === name); const definition = definitions.find((def) => def.name === key);
if (!definition) { if (!definition) {
return null; return null;
} }
return [name, definition.defaultValue || `{{ ${definition.name} }}`]; return [key, definition.defaultValue || `{{ ${definition.name} }}`];
}) })
) )
); );

View File

@ -1,11 +1,15 @@
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, {
parseAxiosError,
json2formData,
arrayToJson,
} from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { type TagId } from '@/portainer/tags/types'; import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { type Environment, EnvironmentCreationTypes } from '../types'; import { type Environment, EnvironmentCreationTypes } from '../types';
import { arrayToJson, buildUrl, json2formData } from './utils'; import { buildUrl } from './utils';
export interface EnvironmentMetadata { export interface EnvironmentMetadata {
groupId?: EnvironmentGroupId; groupId?: EnvironmentGroupId;

View File

@ -12,25 +12,3 @@ export function buildUrl(id?: EnvironmentId, action?: string) {
return baseUrl; return baseUrl;
} }
export function arrayToJson<T>(arr?: Array<T>) {
if (!arr) {
return '';
}
return JSON.stringify(arr);
}
export function json2formData(json: Record<string, unknown>) {
const formData = new FormData();
Object.entries(json).forEach(([key, value]) => {
if (typeof value === 'undefined' || value === null) {
return;
}
formData.append(key, value as string);
});
return formData;
}

View File

@ -28,9 +28,9 @@ interface Props {
isAdditionalFilesFieldVisible?: boolean; isAdditionalFilesFieldVisible?: boolean;
isForcePullVisible?: boolean; isForcePullVisible?: boolean;
isAuthExplanationVisible?: boolean; isAuthExplanationVisible?: boolean;
errors: FormikErrors<GitFormModel>; errors?: FormikErrors<GitFormModel>;
baseWebhookUrl: string; baseWebhookUrl?: string;
webhookId: string; webhookId?: string;
webhooksDocs?: string; webhooksDocs?: string;
} }
@ -88,7 +88,7 @@ export function GitForm({
{isAdditionalFilesFieldVisible && ( {isAdditionalFilesFieldVisible && (
<AdditionalFileField <AdditionalFileField
value={value.AdditionalFiles} value={value.AdditionalFiles || []}
onChange={(value) => handleChange({ AdditionalFiles: value })} onChange={(value) => handleChange({ AdditionalFiles: value })}
errors={errors.AdditionalFiles} errors={errors.AdditionalFiles}
/> />
@ -97,8 +97,8 @@ export function GitForm({
{value.AutoUpdate && ( {value.AutoUpdate && (
<AutoUpdateFieldset <AutoUpdateFieldset
environmentType={environmentType} environmentType={environmentType}
webhookId={webhookId} webhookId={webhookId || ''}
baseWebhookUrl={baseWebhookUrl} baseWebhookUrl={baseWebhookUrl || ''}
value={value.AutoUpdate} value={value.AutoUpdate}
onChange={(value) => handleChange({ AutoUpdate: value })} onChange={(value) => handleChange({ AutoUpdate: value })}
isForcePullVisible={isForcePullVisible} isForcePullVisible={isForcePullVisible}
@ -165,5 +165,5 @@ export function buildGitValidationSchema(
RepositoryURLValid: boolean().default(false), RepositoryURLValid: boolean().default(false),
AutoUpdate: autoUpdateValidation().nullable(), AutoUpdate: autoUpdateValidation().nullable(),
TLSSkipVerify: boolean().default(false), TLSSkipVerify: boolean().default(false),
}).concat(gitAuthValidation(gitCredentials, false)); }).concat(gitAuthValidation(gitCredentials, false)) as SchemaOf<GitFormModel>;
} }

View File

@ -18,7 +18,7 @@ interface Props {
onChange(value: string): void; onChange(value: string): void;
model: RefFieldModel; model: RefFieldModel;
error?: string; error?: string;
isUrlValid: boolean; isUrlValid?: boolean;
stackId?: StackId; stackId?: StackId;
} }

View File

@ -18,7 +18,7 @@ export function RefSelector({
value: string; value: string;
stackId?: StackId; stackId?: StackId;
onChange: (value: string) => void; onChange: (value: string) => void;
isUrlValid: boolean; isUrlValid?: boolean;
}) { }) {
const creds = getAuthentication(model); const creds = getAuthentication(model);
const payload = { const payload = {

View File

@ -1,5 +1,4 @@
export type AutoUpdateMechanism = 'Webhook' | 'Interval'; export type AutoUpdateMechanism = 'Webhook' | 'Interval';
export interface AutoUpdateResponse { export interface AutoUpdateResponse {
/* Auto update interval */ /* Auto update interval */
Interval: string; Interval: string;
@ -26,6 +25,7 @@ export interface RepoConfigResponse {
ConfigFilePath: string; ConfigFilePath: string;
Authentication?: GitAuthenticationResponse; Authentication?: GitAuthenticationResponse;
ConfigHash: string; ConfigHash: string;
TLSSkipVerify: boolean;
} }
export type AutoUpdateModel = { export type AutoUpdateModel = {
@ -52,11 +52,11 @@ export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
export interface GitFormModel extends GitAuthModel { export interface GitFormModel extends GitAuthModel {
RepositoryURL: string; RepositoryURL: string;
RepositoryURLValid: boolean; RepositoryURLValid?: boolean;
ComposeFilePathInRepository: string; ComposeFilePathInRepository: string;
RepositoryAuthentication: boolean; RepositoryAuthentication: boolean;
RepositoryReferenceName?: string; RepositoryReferenceName?: string;
AdditionalFiles: string[]; AdditionalFiles?: string[];
SaveCredential?: boolean; SaveCredential?: boolean;
NewCredentialName?: string; NewCredentialName?: string;
@ -78,3 +78,31 @@ export interface RelativePathModel {
PerDeviceConfigsMatchType?: string; PerDeviceConfigsMatchType?: string;
PerDeviceConfigsGroupMatchType?: string; PerDeviceConfigsGroupMatchType?: string;
} }
export function toGitFormModel(response?: RepoConfigResponse): GitFormModel {
if (!response) {
return {
RepositoryURL: '',
ComposeFilePathInRepository: '',
RepositoryAuthentication: false,
TLSSkipVerify: false,
};
}
const { URL, ReferenceName, ConfigFilePath, Authentication, TLSSkipVerify } =
response;
return {
RepositoryURL: URL,
ComposeFilePathInRepository: ConfigFilePath,
RepositoryReferenceName: ReferenceName,
RepositoryAuthentication: !!(
Authentication &&
(Authentication?.GitCredentialID || Authentication?.Username)
),
RepositoryUsername: Authentication?.Username,
RepositoryPassword: Authentication?.Password,
RepositoryGitCredentialID: Authentication?.GitCredentialID,
TLSSkipVerify,
};
}

View File

@ -28,14 +28,12 @@ export function AppTemplatesList({
selectedId, selectedId,
disabledTypes, disabledTypes,
fixedCategories, fixedCategories,
hideDuplicate,
}: { }: {
templates?: TemplateViewModel[]; templates?: TemplateViewModel[];
onSelect: (template: TemplateViewModel) => void; onSelect: (template: TemplateViewModel) => void;
selectedId?: TemplateViewModel['Id']; selectedId?: TemplateViewModel['Id'];
disabledTypes?: Array<TemplateType>; disabledTypes?: Array<TemplateType>;
fixedCategories?: Array<string>; fixedCategories?: Array<string>;
hideDuplicate?: boolean;
}) { }) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@ -75,7 +73,6 @@ export function AppTemplatesList({
template={template} template={template}
onSelect={onSelect} onSelect={onSelect}
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>}

View File

@ -12,12 +12,10 @@ export function AppTemplatesListItem({
template, template,
onSelect, onSelect,
isSelected, isSelected,
hideDuplicate = false,
}: { }: {
template: TemplateViewModel; template: TemplateViewModel;
onSelect: (template: TemplateViewModel) => void; onSelect: (template: TemplateViewModel) => void;
isSelected: boolean; isSelected: boolean;
hideDuplicate?: boolean;
}) { }) {
const duplicateCustomTemplateType = getCustomTemplateType(template.Type); const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
@ -30,7 +28,6 @@ export function AppTemplatesListItem({
onSelect={() => onSelect(template)} onSelect={() => onSelect(template)}
isSelected={isSelected} isSelected={isSelected}
renderActions={ renderActions={
!hideDuplicate &&
duplicateCustomTemplateType && ( duplicateCustomTemplateType && (
<div className="mr-5 mt-3"> <div className="mr-5 mt-3">
<Button <Button

View File

@ -14,7 +14,7 @@ export function useAppTemplates() {
const registriesQuery = useRegistries(); const registriesQuery = useRegistries();
return useQuery( return useQuery(
'templates', ['templates'],
() => getTemplatesWithRegistry(registriesQuery.data), () => getTemplatesWithRegistry(registriesQuery.data),
{ {
enabled: !!registriesQuery.data, enabled: !!registriesQuery.data,

View File

@ -1,9 +1,17 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { AppTemplate } from '../types'; import { AppTemplate } from '../types';
import { buildUrl } from './build-url'; import { buildUrl } from './build-url';
export function useFetchTemplateFile(id?: AppTemplate['id']) {
return useQuery(['templates', id, 'file'], () => fetchFilePreview(id!), {
enabled: !!id,
});
}
export async function fetchFilePreview(id: AppTemplate['id']) { export async function fetchFilePreview(id: AppTemplate['id']) {
try { try {
const { data } = await axios.post<{ FileContent: string }>( const { data } = await axios.post<{ FileContent: string }>(

View File

@ -2,7 +2,7 @@ import _ from 'lodash';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { Pair } from '../../settings/types'; import { Pair } from '../../settings/types';
import { Platform } from '../../custom-templates/types'; import { Platform } from '../types';
import { import {
AppTemplate, AppTemplate,

View File

@ -9,8 +9,9 @@ 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 { BadgeIcon } from '@@/BadgeIcon';
import { Link } from '@@/Link';
import { Platform } from '../../custom-templates/types'; import { Platform } from '../types';
type Value = { type Value = {
Id: number | string; Id: number | string;
@ -27,16 +28,24 @@ export function TemplateItem({
onSelect, onSelect,
renderActions, renderActions,
isSelected, isSelected,
linkParams,
}: { }: {
template: Value; template: Value;
typeLabel: string; typeLabel: string;
onSelect: () => void; onSelect: () => void;
renderActions: ReactNode; renderActions: ReactNode;
isSelected: boolean; isSelected: boolean;
linkParams?: { to: string; params: object };
}) { }) {
return ( return (
<div className="relative"> <div className="relative">
<BlocklistItem isSelected={isSelected} onClick={() => onSelect()}> <BlocklistItem
isSelected={isSelected}
onClick={() => onSelect()}
as={linkParams ? Link : undefined}
to={linkParams?.to}
params={linkParams?.params}
>
<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}

View File

@ -2,7 +2,7 @@ import { Edit, Plus } from 'lucide-react';
import _ from 'lodash'; import _ from 'lodash';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { CustomTemplate } from '@/react/portainer/custom-templates/types'; import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { DatatableHeader } from '@@/datatables/DatatableHeader'; import { DatatableHeader } from '@@/datatables/DatatableHeader';
import { Table } from '@@/datatables'; import { Table } from '@@/datatables';
@ -22,11 +22,16 @@ export function CustomTemplatesList({
onSelect, onSelect,
onDelete, onDelete,
selectedId, selectedId,
templateLinkParams,
}: { }: {
templates?: CustomTemplate[]; templates?: CustomTemplate[];
onSelect: (template: CustomTemplate['Id']) => void; onSelect?: (template: CustomTemplate['Id']) => void;
onDelete: (template: CustomTemplate['Id']) => void; onDelete: (template: CustomTemplate['Id']) => void;
selectedId: CustomTemplate['Id']; selectedId?: CustomTemplate['Id'];
templateLinkParams?: (template: CustomTemplate) => {
to: string;
params: object;
};
}) { }) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@ -67,6 +72,7 @@ export function CustomTemplatesList({
onSelect={onSelect} onSelect={onSelect}
isSelected={template.Id === selectedId} isSelected={template.Id === selectedId}
onDelete={onDelete} onDelete={onDelete}
linkParams={templateLinkParams?.(template)}
/> />
))} ))}
{!templates && <div className="text-muted text-center">Loading...</div>} {!templates && <div className="text-muted text-center">Loading...</div>}

View File

@ -2,7 +2,7 @@ import { Edit, Trash2 } from 'lucide-react';
import { useCurrentUser } from '@/react/hooks/useUser'; import { useCurrentUser } from '@/react/hooks/useUser';
import { StackType } from '@/react/common/stacks/types'; import { StackType } from '@/react/common/stacks/types';
import { CustomTemplate } from '@/react/portainer/custom-templates/types'; import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@ -14,11 +14,13 @@ export function CustomTemplatesListItem({
onSelect, onSelect,
onDelete, onDelete,
isSelected, isSelected,
linkParams,
}: { }: {
template: CustomTemplate; template: CustomTemplate;
onSelect: (templateId: CustomTemplate['Id']) => void; onSelect?: (templateId: CustomTemplate['Id']) => void;
onDelete: (templateId: CustomTemplate['Id']) => void; onDelete: (templateId: CustomTemplate['Id']) => void;
isSelected: boolean; isSelected: boolean;
linkParams?: { to: string; params: object };
}) { }) {
const { isAdmin, user } = useCurrentUser(); const { isAdmin, user } = useCurrentUser();
const isEditAllowed = isAdmin || template.CreatedByUserId === user.Id; const isEditAllowed = isAdmin || template.CreatedByUserId === user.Id;
@ -27,8 +29,9 @@ export function CustomTemplatesListItem({
<TemplateItem <TemplateItem
template={template} template={template}
typeLabel={getTypeLabel(template.Type)} typeLabel={getTypeLabel(template.Type)}
onSelect={() => onSelect(template.Id)} onSelect={() => onSelect?.(template.Id)}
isSelected={isSelected} isSelected={isSelected}
linkParams={linkParams}
renderActions={ renderActions={
<div className="mr-4 mt-3"> <div className="mr-4 mt-3">
{isEditAllowed && ( {isEditAllowed && (
@ -36,13 +39,14 @@ export function CustomTemplatesListItem({
<Button <Button
as={Link} as={Link}
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
color="secondary" color="secondary"
props={{ props={{
to: '.edit', to: '.edit',
params: { params: {
id: template.Id, templateId: template.Id,
}, },
}} }}
icon={Edit} icon={Edit}

View File

@ -0,0 +1,21 @@
import { CustomTemplate } from '../types';
export function buildUrl({
id,
action,
}: {
id?: CustomTemplate['Id'];
action?: string;
} = {}) {
let base = '/custom_templates';
if (id) {
base = `${base}/${id}`;
}
if (action) {
base = `${base}/${action}`;
}
return base;
}

View File

@ -0,0 +1,8 @@
import { CustomTemplate } from '../types';
export const queryKeys = {
base: () => ['custom-templates'] as const,
item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const,
file: (id: CustomTemplate['Id'], options: { git: boolean }) =>
[...queryKeys.item(id), 'file', options] as const,
};

View File

@ -0,0 +1,188 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, {
json2formData,
parseAxiosError,
} from '@/portainer/services/axios';
import {
mutationOptions,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import { StackType } from '@/react/common/stacks/types';
import { FormValues } from '@/react/edge/templates/custom-templates/CreateView/types';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { Platform } from '../../types';
import { buildUrl } from './build-url';
export function useCreateTemplateMutation() {
const queryClient = useQueryClient();
return useMutation(
createTemplate,
mutationOptions(
withInvalidate(queryClient, [['custom-templates']]),
withGlobalError('Failed to create template')
)
);
}
function createTemplate({
Method,
Git,
...values
}: FormValues & { EdgeTemplate?: boolean }) {
switch (Method) {
case 'editor':
return createTemplateFromText(values);
case 'upload':
return createTemplateFromFile(values);
case 'repository':
return createTemplateFromGit({ ...values, ...Git });
default:
throw new Error('Unknown method');
}
}
/**
* Payload for creating a custom template from file content.
*/
interface CustomTemplateFromFileContentPayload {
/** URL of the template's logo. */
Logo: string;
/** Title of the template. Required. */
Title: string;
/** Description of the template. Required. */
Description: string;
/** A note that will be displayed in the UI. Supports HTML content. */
Note: string;
/** Platform associated with the template. */
Platform: Platform;
/** Type of created stack. Required. */
Type: StackType;
/** Content of the stack file. Required. */
FileContent: string;
/** Definitions of variables in the stack file. */
Variables: VariableDefinition[];
/** Indicates if this template is for Edge Stack. */
EdgeTemplate?: boolean;
}
async function createTemplateFromText(
values: CustomTemplateFromFileContentPayload
) {
try {
const { data } = await axios.post<CustomTemplate>(
buildUrl({ action: 'create/string' }),
values
);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}
interface CustomTemplateFromFilePayload {
/** Title of the template */
Title: string;
/** Description of the template */
Description: string;
/** A note that will be displayed in the UI */
Note: string;
/** Platform associated with the template */
Platform: Platform;
/** Type of created stack */
Type: StackType;
/** File to upload */
File?: File;
/** URL of the template's logo */
Logo?: string;
/** Definitions of variables in the stack file */
Variables?: VariableDefinition[];
/** Indicates if this template is for Edge Stack. */
EdgeTemplate?: boolean;
}
async function createTemplateFromFile(values: CustomTemplateFromFilePayload) {
try {
if (!values.File) {
throw new Error('No file provided');
}
const payload = json2formData({
Platform: values.Platform,
Type: values.Type,
Title: values.Title,
Description: values.Description,
Note: values.Note,
Logo: values.Logo,
File: values.File,
Variables: values.Variables,
EdgeTemplate: values.EdgeTemplate,
});
const { data } = await axios.post<CustomTemplate>(
buildUrl({ action: 'create/file' }),
payload,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}
/**
* Payload for creating a custom template from a Git repository.
*/
interface CustomTemplateFromGitRepositoryPayload {
/** URL of the template's logo. */
Logo: string;
/** Title of the template. Required. */
Title: string;
/** Description of the template. Required. */
Description: string;
/** A note that will be displayed in the UI. Supports HTML content. */
Note: string;
/** Platform associated with the template. */
Platform: Platform;
/** Type of created stack. Required. */
Type: StackType;
/** URL of a Git repository hosting the Stack file. Required. */
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 when RepositoryAuthentication is true. */
RepositoryUsername?: string;
/** Password used in basic authentication when RepositoryAuthentication is true. */
RepositoryPassword?: string;
/** Path to the Stack file inside the Git repository. */
ComposeFilePathInRepository: string;
/** Definitions of variables in the stack file. */
Variables: VariableDefinition[];
/** Indicates whether to skip SSL verification when cloning the Git repository. */
TLSSkipVerify: boolean;
/** Indicates if the Kubernetes template is created from a Docker Compose file. */
IsComposeFormat?: boolean;
/** Indicates if this template is for Edge Stack. */
EdgeTemplate?: boolean;
}
async function createTemplateFromGit(
values: CustomTemplateFromGitRepositoryPayload
) {
try {
const { data } = await axios.post<CustomTemplate>(
buildUrl({ action: 'create/repository' }),
values
);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}

View File

@ -0,0 +1,25 @@
import { useQuery } from 'react-query';
import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { CustomTemplate } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
export async function getCustomTemplate(id: CustomTemplate['Id']) {
try {
const { data } = await axios.get<CustomTemplate>(buildUrl({ id }));
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to get custom template');
}
}
export function useCustomTemplate(id?: CustomTemplate['Id']) {
return useQuery(queryKeys.item(id!), () => getCustomTemplate(id!), {
...withGlobalError('Unable to retrieve custom template'),
enabled: !!id,
});
}

View File

@ -0,0 +1,67 @@
import { useMutation, useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { CustomTemplate } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
type CustomTemplateFileContent = {
FileContent: string;
};
export function useCustomTemplateFile(id?: CustomTemplate['Id'], git = false) {
return useQuery(
id ? queryKeys.file(id, { git }) : [],
() => getCustomTemplateFile({ id: id!, git }),
{
...withGlobalError('Failed to get custom template file'),
enabled: !!id,
}
);
}
export function useCustomTemplateFileMutation() {
return useMutation({
mutationFn: getCustomTemplateFile,
...withGlobalError('Failed to get custom template file'),
});
}
export function getCustomTemplateFile({
git,
id,
}: {
id: CustomTemplate['Id'];
git: boolean;
}) {
return git ? getCustomTemplateGitFetch(id) : getCustomTemplateFileContent(id);
}
async function getCustomTemplateFileContent(id: number) {
try {
const {
data: { FileContent },
} = await axios.get<CustomTemplateFileContent>(
buildUrl({ id, action: 'file' })
);
return FileContent;
} catch (e) {
throw parseAxiosError(e, 'Unable to get custom template file content');
}
}
async function getCustomTemplateGitFetch(id: number) {
try {
const {
data: { FileContent },
} = await axios.put<CustomTemplateFileContent>(
buildUrl({ id, action: 'git_fetch' })
);
return FileContent;
} catch (e) {
throw parseAxiosError(e, 'Unable to get custom template file content');
}
}

View File

@ -0,0 +1,27 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { CustomTemplate } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
export async function getCustomTemplates() {
try {
const { data } = await axios.get<CustomTemplate[]>(buildUrl());
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to get custom templates');
}
}
export function useCustomTemplates<T = Array<CustomTemplate>>({
select,
}: { select?(templates: Array<CustomTemplate>): T } = {}) {
return useQuery(queryKeys.base(), () => getCustomTemplates(), {
select,
...withGlobalError('Unable to retrieve custom templates'),
});
}

View File

@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from 'react-query';
import {
mutationOptions,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { CustomTemplate } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
export function useDeleteTemplateMutation() {
const queryClient = useQueryClient();
return useMutation(
deleteTemplate,
mutationOptions(
withInvalidate(queryClient, [queryKeys.base()]),
withGlobalError('Unable to delete custom template')
)
);
}
export async function deleteTemplate(id: CustomTemplate['Id']) {
try {
await axios.delete(buildUrl({ id }));
} catch (e) {
throw parseAxiosError(e, 'Unable to get custom template');
}
}

View File

@ -0,0 +1,92 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import { StackType } from '@/react/common/stacks/types';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
import { CustomTemplate } from '../types';
import { Platform } from '../../types';
import { buildUrl } from './build-url';
export function useUpdateTemplateMutation() {
const queryClient = useQueryClient();
return useMutation(
updateTemplate,
mutationOptions(
withInvalidate(queryClient, [['custom-templates']]),
withGlobalError('Failed to update template')
)
);
}
/**
* Payload for updating a custom template
*/
interface CustomTemplateUpdatePayload {
/** URL of the template's logo */
Logo?: string;
/** Title of the template */
Title: string;
/** Description of the template */
Description: string;
/** A note that will be displayed in the UI. Supports HTML content */
Note?: string;
/**
* Platform associated to the template.
* Required for Docker stacks
*/
Platform?: Platform;
/**
* Type of created stack
* Required
*/
Type: StackType;
/** 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.
* Required when RepositoryAuthentication is true and RepositoryUsername/RepositoryPassword are not provided
*/
RepositoryGitCredentialID?: number;
/** Path to the Stack file inside the Git repository */
ComposeFilePathInRepository?: string;
/** Content of stack file */
FileContent?: string;
/** Definitions of variables in the stack file */
Variables?: VariableDefinition[];
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
TLSSkipVerify?: boolean;
/** IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file */
IsComposeFormat?: boolean;
/** EdgeTemplate indicates if this template purpose for Edge Stack */
EdgeTemplate?: boolean;
}
async function updateTemplate(
values: CustomTemplateUpdatePayload & { id: CustomTemplate['Id'] }
) {
try {
const { data } = await axios.put<CustomTemplate>(
buildUrl({ id: values.id }),
values
);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}

View File

@ -1,15 +1,10 @@
import { UserId } from '@/portainer/users/types'; import { UserId } from '@/portainer/users/types';
import { StackType } from '@/react/common/stacks/types'; import { StackType } from '@/react/common/stacks/types';
import { ResourceControlResponse } from '../access-control/types'; import { ResourceControlResponse } from '../../access-control/types';
import { RepoConfigResponse } from '../gitops/types'; import { RepoConfigResponse } from '../../gitops/types';
import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { VariableDefinition } from './components/CustomTemplatesVariablesDefinitionField'; import { Platform } from '../types';
export enum Platform {
LINUX = 1,
WINDOWS,
}
export type CustomTemplate = { export type CustomTemplate = {
Id: number; Id: number;
@ -89,6 +84,9 @@ export type CustomTemplate = {
* @example false * @example false
*/ */
IsComposeFormat: boolean; IsComposeFormat: boolean;
/** EdgeTemplate indicates if this template purpose for Edge Stack */
EdgeTemplate: boolean;
}; };
export type CustomTemplateFileContent = { export type CustomTemplateFileContent = {

View File

@ -0,0 +1,4 @@
export enum Platform {
LINUX = 1,
WINDOWS,
}

View File

@ -55,7 +55,7 @@ export function EdgeComputeSidebar() {
)} )}
<SidebarParent <SidebarParent
icon={Edit} icon={Edit}
label="Templates" label="Edge Templates"
to="edge.templates" to="edge.templates"
data-cy="edgeSidebar-templates" data-cy="edgeSidebar-templates"
> >
@ -66,12 +66,12 @@ export function EdgeComputeSidebar() {
isSubMenu isSubMenu
data-cy="edgeSidebar-appTemplates" data-cy="edgeSidebar-appTemplates"
/> />
{/* <SidebarItem <SidebarItem
label="Custom" label="Custom"
to="edge.templates.custom" to="edge.templates.custom"
isSubMenu isSubMenu
data-cy="edgeSidebar-customTemplates" data-cy="edgeSidebar-customTemplates"
/> */} />
</SidebarParent> </SidebarParent>
</SidebarSection> </SidebarSection>
); );

View File

@ -179,7 +179,7 @@ module.exports = {
}, },
watchOptions: { watchOptions: {
ignored: /node_modules/, ignored: /node_modules/,
aggregateTimeout: 500, aggregateTimeout: 200,
}, },
resolve: { resolve: {
alias: { alias: {