mirror of https://github.com/portainer/portainer
feat(edge/templates): introduce custom templates [EE-6208] (#10561)
parent
a0f583a17d
commit
68950fbb24
|
@ -103,6 +103,8 @@ type customTemplateFromFileContentPayload struct {
|
|||
FileContent string `validate:"required"`
|
||||
// Definitions of variables in the stack file
|
||||
Variables []portainer.CustomTemplateVariableDefinition
|
||||
// EdgeTemplate indicates if this template purpose for Edge Stack
|
||||
EdgeTemplate bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error {
|
||||
|
@ -169,6 +171,7 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p
|
|||
Type: (payload.Type),
|
||||
Logo: payload.Logo,
|
||||
Variables: payload.Variables,
|
||||
EdgeTemplate: payload.EdgeTemplate,
|
||||
}
|
||||
|
||||
templateFolder := strconv.Itoa(customTemplateID)
|
||||
|
@ -218,6 +221,8 @@ type customTemplateFromGitRepositoryPayload struct {
|
|||
TLSSkipVerify bool `example:"false"`
|
||||
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
|
||||
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 {
|
||||
|
@ -264,7 +269,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
|
|||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @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) {
|
||||
var payload customTemplateFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
|
@ -283,6 +288,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
|
|||
Logo: payload.Logo,
|
||||
Variables: payload.Variables,
|
||||
IsComposeFormat: payload.IsComposeFormat,
|
||||
EdgeTemplate: payload.EdgeTemplate,
|
||||
}
|
||||
|
||||
getProjectPath := func() string {
|
||||
|
@ -367,6 +373,8 @@ type customTemplateFromFileUploadPayload struct {
|
|||
FileContent []byte
|
||||
// Definitions of variables in the stack file
|
||||
Variables []portainer.CustomTemplateVariableDefinition
|
||||
// EdgeTemplate indicates if this template purpose for Edge Stack
|
||||
EdgeTemplate bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
|
@ -419,8 +427,15 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -444,7 +459,7 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
|
|||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @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) {
|
||||
payload := &customTemplateFromFileUploadPayload{}
|
||||
err := payload.Validate(r)
|
||||
|
@ -463,6 +478,7 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
|||
Logo: payload.Logo,
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Variables: payload.Variables,
|
||||
EdgeTemplate: payload.EdgeTemplate,
|
||||
}
|
||||
|
||||
templateFolder := strconv.Itoa(customTemplateID)
|
||||
|
|
|
@ -59,6 +59,8 @@ type customTemplateUpdatePayload struct {
|
|||
TLSSkipVerify bool `example:"false"`
|
||||
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
|
||||
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 {
|
||||
|
@ -161,6 +163,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
|||
customTemplate.Type = payload.Type
|
||||
customTemplate.Variables = payload.Variables
|
||||
customTemplate.IsComposeFormat = payload.IsComposeFormat
|
||||
customTemplate.EdgeTemplate = payload.EdgeTemplate
|
||||
|
||||
if payload.RepositoryURL != "" {
|
||||
if !govalidator.IsURL(payload.RepositoryURL) {
|
||||
|
|
|
@ -18,6 +18,7 @@ type templateFileFormat struct {
|
|||
}
|
||||
|
||||
// @id EdgeTemplateList
|
||||
// @deprecated
|
||||
// @summary Fetches the list of Edge Templates
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_templates
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
|
@ -25,7 +26,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -16,6 +16,12 @@ func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *h
|
|||
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)
|
||||
|
||||
redirectedRequest := r.Clone(r.Context())
|
||||
|
|
|
@ -187,6 +187,8 @@ type (
|
|||
GitConfig *gittypes.RepoConfig `json:"GitConfig"`
|
||||
// IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file
|
||||
IsComposeFormat bool `example:"false"`
|
||||
// EdgeTemplate indicates if this template purpose for Edge Stack
|
||||
EdgeTemplate bool `example:"false"`
|
||||
}
|
||||
|
||||
// CustomTemplateID represents a custom template identifier
|
||||
|
|
|
@ -62,7 +62,7 @@ angular
|
|||
|
||||
const stacksNew = {
|
||||
name: 'edge.stacks.new',
|
||||
url: '/new',
|
||||
url: '/new?templateId',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createEdgeStackView',
|
||||
|
@ -150,6 +150,44 @@ angular
|
|||
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);
|
||||
|
|
|
@ -13,6 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
|||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
|
@ -99,4 +100,8 @@ export const componentsModule = angular
|
|||
'onChange',
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'edgeStackCreateTemplateFieldset',
|
||||
r2a(withReactQuery(TemplateFieldset), ['onChange', 'value', 'onChangeFile'])
|
||||
).name;
|
||||
|
|
|
@ -3,12 +3,26 @@ import angular from 'angular';
|
|||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
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';
|
||||
|
||||
export const templatesModule = angular
|
||||
.module('portainer.app.react.components.templates', [])
|
||||
|
||||
.component(
|
||||
'edgeAppTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(ListView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeCreateCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeEditCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(EditView)), [])
|
||||
).name;
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
|
@ -5,11 +5,14 @@ import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
|||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
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 {
|
||||
/* @ngInject */
|
||||
constructor($state, $window, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async, $scope) {
|
||||
Object.assign(this, { $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, Notifications, FormHelper, $async, $scope });
|
||||
|
||||
this.formValues = {
|
||||
Name: '',
|
||||
|
@ -41,6 +44,8 @@ export default class CreateEdgeStackViewController {
|
|||
hasKubeEndpoint: false,
|
||||
endpointTypes: [],
|
||||
baseWebhookUrl: baseEdgeStackWebhookUrl(),
|
||||
isEdit: false,
|
||||
selectedTemplate: null,
|
||||
};
|
||||
|
||||
this.edgeGroups = null;
|
||||
|
@ -57,6 +62,16 @@ export default class CreateEdgeStackViewController {
|
|||
this.hasType = this.hasType.bind(this);
|
||||
this.onChangeDeploymentType = this.onChangeDeploymentType.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) {
|
||||
|
@ -70,7 +85,7 @@ export default class CreateEdgeStackViewController {
|
|||
const metadata = { type: methodLabel(this.state.Method), format };
|
||||
|
||||
if (metadata.type === 'template') {
|
||||
metadata.templateName = this.selectedTemplate.title;
|
||||
metadata.templateName = this.state.selectedTemplate && this.state.selectedTemplate.title;
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
this.edgeGroups = await this.EdgeGroupService.groups();
|
||||
|
@ -102,6 +129,11 @@ export default class CreateEdgeStackViewController {
|
|||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||
}
|
||||
|
||||
const templateId = this.$state.params.templateId;
|
||||
if (templateId) {
|
||||
this.preSelectTemplate(templateId);
|
||||
}
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
|
|
|
@ -57,6 +57,8 @@
|
|||
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
|
||||
form-values="$ctrl.formValues"
|
||||
state="$ctrl.state"
|
||||
template="$ctrl.state.selectedTemplate"
|
||||
on-change-template="($ctrl.onChangeTemplate)"
|
||||
></edge-stacks-docker-compose-form>
|
||||
|
||||
<edge-stacks-kube-manifest-form
|
||||
|
|
|
@ -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';
|
||||
|
||||
class DockerComposeFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, EdgeTemplateService, Notifications) {
|
||||
Object.assign(this, { $async, EdgeTemplateService, Notifications });
|
||||
constructor($async, Notifications) {
|
||||
Object.assign(this, { $async, Notifications });
|
||||
|
||||
this.methodOptions = [editor, upload, git, edgeStackTemplate];
|
||||
|
||||
this.selectedTemplate = null;
|
||||
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onChangeFile = this.onChangeFile.bind(this);
|
||||
this.onChangeTemplate = this.onChangeTemplate.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
||||
}
|
||||
|
@ -29,24 +25,13 @@ class DockerComposeFormController {
|
|||
onChangeMethod(method) {
|
||||
this.state.Method = method;
|
||||
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) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.StackFileContent = value;
|
||||
this.state.isEditorDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
onChangeFile(value) {
|
||||
|
@ -54,17 +39,6 @@ class DockerComposeFormController {
|
|||
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;
|
||||
|
|
|
@ -1,14 +1,25 @@
|
|||
<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>
|
||||
|
||||
<!-- 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
|
||||
ng-if="$ctrl.state.Method === 'editor'"
|
||||
ng-if="$ctrl.state.Method === 'editor' || ($ctrl.state.Method === 'template' && $ctrl.template)"
|
||||
identifier="stack-creation-editor"
|
||||
value="$ctrl.formValues.StackFileContent"
|
||||
on-change="($ctrl.onChangeFileContent)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
read-only="$ctrl.state.Method === 'template' && $ctrl.template.GitConfig"
|
||||
>
|
||||
<editor-description>
|
||||
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-form>
|
||||
|
||||
<git-form
|
||||
ng-if="$ctrl.state.Method === 'repository'"
|
||||
<div ng-if="$ctrl.state.Method == 'repository'">
|
||||
<git-form
|
||||
value="$ctrl.formValues"
|
||||
on-change="($ctrl.onChangeFormValues)"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
docs-links
|
||||
></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 -->
|
||||
></git-form>
|
||||
</div>
|
||||
|
|
|
@ -7,5 +7,7 @@ export const edgeStacksDockerComposeForm = {
|
|||
bindings: {
|
||||
formValues: '=',
|
||||
state: '=',
|
||||
template: '<',
|
||||
onChangeTemplate: '<',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
|
@ -13,7 +12,7 @@ class KubeCreateCustomTemplateViewController {
|
|||
this.methodOptions = [editor, upload, git];
|
||||
|
||||
this.templates = null;
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.state = {
|
||||
method: 'editor',
|
||||
|
@ -83,7 +82,7 @@ class KubeCreateCustomTemplateViewController {
|
|||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
|
|
|
@ -24,8 +24,8 @@ export default class KubeCustomTemplatesViewController {
|
|||
this.selectTemplate = this.selectTemplate.bind(this);
|
||||
}
|
||||
|
||||
selectTemplate(template) {
|
||||
this.$state.go('kubernetes.deploy', { templateId: template });
|
||||
selectTemplate(templateId) {
|
||||
this.$state.go('kubernetes.deploy', { templateId });
|
||||
}
|
||||
|
||||
isEditAllowed(template) {
|
||||
|
@ -36,7 +36,8 @@ export default class KubeCustomTemplatesViewController {
|
|||
getTemplates() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates(3);
|
||||
const templates = await this.CustomTemplateService.customTemplates(3);
|
||||
this.templates = templates.filter((t) => !t.EdgeTemplate);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { getFilePreview } from '@/react/portainer/gitops/gitops.service';
|
||||
import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
|
@ -11,7 +10,7 @@ class KubeEditCustomTemplateViewController {
|
|||
constructor($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 = {
|
||||
Variables: [],
|
||||
|
@ -112,7 +111,7 @@ class KubeEditCustomTemplateViewController {
|
|||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
|
|
|
@ -4,14 +4,14 @@ import stripAnsi from 'strip-ansi';
|
|||
|
||||
import PortainerError from '@/portainer/error';
|
||||
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 { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
|
@ -25,7 +25,7 @@ class KubernetesDeployController {
|
|||
this.StackService = StackService;
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.deployOptions = [{ ...kubernetes, value: KubernetesDeployManifestTypes.KUBERNETES }];
|
||||
|
||||
|
@ -72,7 +72,7 @@ class KubernetesDeployController {
|
|||
RepositoryPassword: '',
|
||||
AdditionalFiles: [],
|
||||
ComposeFilePathInRepository: '',
|
||||
Variables: {},
|
||||
Variables: [],
|
||||
AutoUpdate: parseAutoUpdateResponse(),
|
||||
TLSSkipVerify: false,
|
||||
Name: '',
|
||||
|
@ -220,7 +220,7 @@ class KubernetesDeployController {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
@ -26,6 +26,7 @@ export const ngModule = angular
|
|||
'value',
|
||||
'onChange',
|
||||
'definitions',
|
||||
'errors',
|
||||
])
|
||||
)
|
||||
.component('customTemplatesVariablesField', VariablesFieldAngular)
|
||||
|
@ -46,7 +47,6 @@ export const ngModule = angular
|
|||
'selectedId',
|
||||
'disabledTypes',
|
||||
'fixedCategories',
|
||||
'hideDuplicate',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
@ -56,6 +56,7 @@ export const ngModule = angular
|
|||
'onSelect',
|
||||
'templates',
|
||||
'selectedId',
|
||||
'templateLinkParams',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
|
|
@ -101,3 +101,30 @@ export function isAxiosError<
|
|||
>(error: unknown): error is AxiosError<ResponseType> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import _ from 'lodash';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation';
|
||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
|
@ -26,7 +25,7 @@ class CreateCustomTemplateViewController {
|
|||
|
||||
this.buildMethods = [editor, upload, git];
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.formValues = {
|
||||
Title: '',
|
||||
|
@ -207,7 +206,7 @@ class CreateCustomTemplateViewController {
|
|||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import _ from 'lodash-es';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
class CustomTemplatesViewController {
|
||||
/* @ngInject */
|
||||
|
@ -34,7 +34,7 @@ class CustomTemplatesViewController {
|
|||
this.StateManager = StateManager;
|
||||
this.StackService = StackService;
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.DOCKER_STANDALONE = 'DOCKER_STANDALONE';
|
||||
this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
|
||||
|
@ -93,7 +93,8 @@ class CustomTemplatesViewController {
|
|||
}
|
||||
async getTemplatesAsync() {
|
||||
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) {
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,7 @@ import { ResourceControlViewModel } from '@/react/portainer/access-control/model
|
|||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
|
||||
class EditCustomTemplateViewController {
|
||||
|
@ -13,7 +12,7 @@ class EditCustomTemplateViewController {
|
|||
constructor($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 = {
|
||||
Variables: [],
|
||||
|
@ -178,7 +177,7 @@ class EditCustomTemplateViewController {
|
|||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/
|
|||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
|
||||
angular
|
||||
.module('portainer.app')
|
||||
|
@ -28,13 +28,12 @@ angular
|
|||
FormHelper,
|
||||
StackHelper,
|
||||
ContainerHelper,
|
||||
CustomTemplateService,
|
||||
ContainerService,
|
||||
endpoint
|
||||
) {
|
||||
$scope.onChangeTemplateId = onChangeTemplateId;
|
||||
$scope.onChangeTemplateVariables = onChangeTemplateVariables;
|
||||
$scope.isTemplateVariablesEnabled = isBE;
|
||||
$scope.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
$scope.buildAnalyticsProperties = buildAnalyticsProperties;
|
||||
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
|
||||
$scope.buildMethods = [editor, upload, git, customTemplate];
|
||||
|
@ -54,7 +53,7 @@ angular
|
|||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
EnableWebhook: false,
|
||||
Variables: {},
|
||||
Variables: [],
|
||||
AutoUpdate: parseAutoUpdateResponse(),
|
||||
TLSSkipVerify: false,
|
||||
};
|
||||
|
@ -313,7 +312,7 @@ angular
|
|||
}
|
||||
|
||||
if (template.Variables && template.Variables.length > 0) {
|
||||
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, '']));
|
||||
const variables = getVariablesFieldDefaultValues(template.Variables);
|
||||
onChangeTemplateVariables(variables);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
@ -23,7 +23,7 @@ export function BlocklistItem<T extends ElementType>({
|
|||
type="button"
|
||||
className={clsx(
|
||||
className,
|
||||
'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 text-left',
|
||||
'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 no-link text-left',
|
||||
{
|
||||
'blocklist-item--selected': isSelected,
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
@ -6,6 +7,10 @@ import { CodeEditor } from '@@/CodeEditor';
|
|||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
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 = {
|
||||
tooltip: (
|
||||
|
@ -91,6 +96,8 @@ export function WebEditorForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 col-lg-12">
|
||||
<CodeEditor
|
||||
|
@ -104,11 +111,59 @@ export function WebEditorForm({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 col-lg-12">{error}</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, '');
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
|
||||
interface Props {
|
||||
import { FormSection } from './FormSection';
|
||||
|
||||
interface Props extends AutomationTestingProps {
|
||||
submitLabel: string;
|
||||
loadingText: string;
|
||||
isLoading: boolean;
|
||||
isValid: boolean;
|
||||
'data-cy'?: string;
|
||||
}
|
||||
|
||||
export function FormActions({
|
||||
|
@ -19,6 +22,7 @@ export function FormActions({
|
|||
'data-cy': dataCy,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
|
@ -34,5 +38,6 @@ export function FormActions({
|
|||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -34,7 +34,6 @@ export function AppTemplatesView() {
|
|||
onSelect={(template) => setSelectedTemplateId(template.Id)}
|
||||
disabledTypes={[TemplateType.Container]}
|
||||
fixedCategories={['edge']}
|
||||
hideDuplicate
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -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;
|
||||
}
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EditView } from './EditView';
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { success as notifySuccess } from '@/portainer/services/notifications';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import {
|
||||
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), {
|
||||
staleTime: 20,
|
||||
enabled: isBE && enabled,
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
|
|
|
@ -4,7 +4,9 @@ import { FormikErrors } from 'formik';
|
|||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
interface Values {
|
||||
import { CustomTemplate } from '../../templates/custom-templates/types';
|
||||
|
||||
export interface Values {
|
||||
Title: string;
|
||||
Description: string;
|
||||
Note: string;
|
||||
|
@ -87,11 +89,26 @@ export function CommonFields({
|
|||
}
|
||||
|
||||
export function validation({
|
||||
currentTemplateId,
|
||||
templates = [],
|
||||
title,
|
||||
}: {
|
||||
currentTemplateId?: CustomTemplate['Id'];
|
||||
templates?: Array<CustomTemplate>;
|
||||
title?: { pattern: string; error: string };
|
||||
} = {}): 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) {
|
||||
const pattern = new RegExp(title.pattern);
|
||||
titleSchema = titleSchema.matches(pattern, title.error);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { SchemaOf, array, object, string } from 'yup';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { InputList } from '@@/form-components/InputList';
|
||||
import { ItemProps } from '@@/form-components/InputList/InputList';
|
||||
import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList';
|
||||
|
||||
export interface VariableDefinition {
|
||||
name: string;
|
||||
|
@ -12,10 +12,12 @@ export interface VariableDefinition {
|
|||
description: string;
|
||||
}
|
||||
|
||||
export type Values = VariableDefinition[];
|
||||
|
||||
interface Props {
|
||||
value: VariableDefinition[];
|
||||
onChange: (value: VariableDefinition[]) => void;
|
||||
errors?: FormikErrors<VariableDefinition>[];
|
||||
value: Values;
|
||||
onChange: (value: Values) => void;
|
||||
errors?: ArrayError<Values>;
|
||||
isVariablesNamesFromParent?: boolean;
|
||||
}
|
||||
|
||||
|
@ -107,3 +109,16 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) {
|
|||
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());
|
||||
}
|
||||
|
|
|
@ -1,2 +1,8 @@
|
|||
export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField';
|
||||
export type { VariableDefinition } from './CustomTemplatesVariablesDefinitionField';
|
||||
export {
|
||||
CustomTemplatesVariablesDefinitionField,
|
||||
validation as variablesValidation,
|
||||
} from './CustomTemplatesVariablesDefinitionField';
|
||||
export type {
|
||||
VariableDefinition,
|
||||
Values as DefinitionFieldValues,
|
||||
} from './CustomTemplatesVariablesDefinitionField';
|
||||
|
|
|
@ -4,7 +4,7 @@ import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/C
|
|||
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
Variables,
|
||||
Values,
|
||||
} from './CustomTemplatesVariablesField';
|
||||
|
||||
export default {
|
||||
|
@ -34,10 +34,8 @@ const definitions: VariableDefinition[] = [
|
|||
];
|
||||
|
||||
function Template() {
|
||||
const [value, setValue] = useState<Variables>(
|
||||
Object.fromEntries(
|
||||
definitions.map((def) => [def.name, def.defaultValue || ''])
|
||||
)
|
||||
const [value, setValue] = useState<Values>(
|
||||
definitions.map((def) => ({ key: def.name, value: def.defaultValue || '' }))
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import { array, object, string } from 'yup';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export type Variables = Record<string, string>;
|
||||
export type Values = Array<{ key: string; value?: string }>;
|
||||
|
||||
interface Props {
|
||||
value: Variables;
|
||||
definitions?: VariableDefinition[];
|
||||
onChange: (value: Variables) => void;
|
||||
errors?: ArrayError<Values>;
|
||||
value: Values;
|
||||
definitions: VariableDefinition[] | undefined;
|
||||
onChange: (value: Values) => void;
|
||||
}
|
||||
|
||||
export function CustomTemplatesVariablesField({
|
||||
errors,
|
||||
value,
|
||||
definitions,
|
||||
onChange,
|
||||
|
@ -23,32 +29,88 @@ export function CustomTemplatesVariablesField({
|
|||
|
||||
return (
|
||||
<FormSection title="Template Variables">
|
||||
{definitions.map((def) => {
|
||||
const inputId = `${def.name}-input`;
|
||||
const variable = value[def.name] || '';
|
||||
return (
|
||||
<FormControl
|
||||
required={!def.defaultValue}
|
||||
label={def.label}
|
||||
key={def.name}
|
||||
inputId={inputId}
|
||||
tooltip={def.description}
|
||||
size="small"
|
||||
>
|
||||
<Input
|
||||
name={`variables.${def.name}`}
|
||||
value={variable}
|
||||
id={inputId}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
[def.name]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{definitions.map((definition, index) => (
|
||||
<VariableFieldItem
|
||||
key={definition.name}
|
||||
definition={definition}
|
||||
value={value.find((v) => v.key === definition.name)?.value || ''}
|
||||
error={getError(errors, index)}
|
||||
onChange={(fieldValue) => {
|
||||
onChange(
|
||||
value.map((v) =>
|
||||
v.key === definition.name ? { ...v, value: fieldValue } : v
|
||||
)
|
||||
);
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typeof errors === 'string' && <FormError>{errors}</FormError>}
|
||||
</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;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -1 +1,7 @@
|
|||
export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField';
|
||||
export {
|
||||
CustomTemplatesVariablesField,
|
||||
type Values as VariablesFieldValue,
|
||||
validation as variablesFieldValidation,
|
||||
} from './CustomTemplatesVariablesField';
|
||||
|
||||
export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
|
||||
import { Platform } from '../types';
|
||||
import { Platform } from '../../templates/types';
|
||||
|
||||
const platformOptions = [
|
||||
{ label: 'Linux', value: Platform.LINUX },
|
||||
|
|
|
@ -1,34 +1,48 @@
|
|||
import _ from 'lodash';
|
||||
import Mustache from 'mustache';
|
||||
|
||||
import { isBE } from '../../feature-flags/feature-flags.service';
|
||||
|
||||
import { VariableDefinition } from './CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
import { VariablesFieldValue } from './CustomTemplatesVariablesField';
|
||||
|
||||
export const isTemplateVariablesEnabled = isBE;
|
||||
|
||||
export function getTemplateVariables(templateStr: string) {
|
||||
const template = validateAndParse(templateStr);
|
||||
const [template, error] = validateAndParse(templateStr);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
return [null, error] as const;
|
||||
}
|
||||
|
||||
return template
|
||||
return [
|
||||
template
|
||||
.filter(([type, value]) => type === 'name' && value)
|
||||
.map(([, value]) => ({
|
||||
name: value,
|
||||
label: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
}));
|
||||
})),
|
||||
null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
function validateAndParse(templateStr: string) {
|
||||
type TemplateSpans = ReturnType<typeof Mustache.parse>;
|
||||
function validateAndParse(
|
||||
templateStr: string
|
||||
): readonly [TemplateSpans, null] | readonly [null, string] {
|
||||
if (!templateStr) {
|
||||
return [];
|
||||
return [[] as TemplateSpans, null] as const;
|
||||
}
|
||||
|
||||
try {
|
||||
return Mustache.parse(templateStr);
|
||||
return [Mustache.parse(templateStr), null] as const;
|
||||
} 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(
|
||||
template: string,
|
||||
variables: Record<string, string>,
|
||||
variables: VariablesFieldValue,
|
||||
definitions: VariableDefinition[]
|
||||
) {
|
||||
const state = Object.fromEntries(
|
||||
_.compact(
|
||||
Object.entries(variables).map(([name, value]) => {
|
||||
variables.map(({ key, 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [name, definition.defaultValue || `{{ ${definition.name} }}`];
|
||||
return [key, definition.defaultValue || `{{ ${definition.name} }}`];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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 TagId } from '@/portainer/tags/types';
|
||||
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
||||
|
||||
import { type Environment, EnvironmentCreationTypes } from '../types';
|
||||
|
||||
import { arrayToJson, buildUrl, json2formData } from './utils';
|
||||
import { buildUrl } from './utils';
|
||||
|
||||
export interface EnvironmentMetadata {
|
||||
groupId?: EnvironmentGroupId;
|
||||
|
|
|
@ -12,25 +12,3 @@ export function buildUrl(id?: EnvironmentId, action?: string) {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -28,9 +28,9 @@ interface Props {
|
|||
isAdditionalFilesFieldVisible?: boolean;
|
||||
isForcePullVisible?: boolean;
|
||||
isAuthExplanationVisible?: boolean;
|
||||
errors: FormikErrors<GitFormModel>;
|
||||
baseWebhookUrl: string;
|
||||
webhookId: string;
|
||||
errors?: FormikErrors<GitFormModel>;
|
||||
baseWebhookUrl?: string;
|
||||
webhookId?: string;
|
||||
webhooksDocs?: string;
|
||||
}
|
||||
|
||||
|
@ -88,7 +88,7 @@ export function GitForm({
|
|||
|
||||
{isAdditionalFilesFieldVisible && (
|
||||
<AdditionalFileField
|
||||
value={value.AdditionalFiles}
|
||||
value={value.AdditionalFiles || []}
|
||||
onChange={(value) => handleChange({ AdditionalFiles: value })}
|
||||
errors={errors.AdditionalFiles}
|
||||
/>
|
||||
|
@ -97,8 +97,8 @@ export function GitForm({
|
|||
{value.AutoUpdate && (
|
||||
<AutoUpdateFieldset
|
||||
environmentType={environmentType}
|
||||
webhookId={webhookId}
|
||||
baseWebhookUrl={baseWebhookUrl}
|
||||
webhookId={webhookId || ''}
|
||||
baseWebhookUrl={baseWebhookUrl || ''}
|
||||
value={value.AutoUpdate}
|
||||
onChange={(value) => handleChange({ AutoUpdate: value })}
|
||||
isForcePullVisible={isForcePullVisible}
|
||||
|
@ -165,5 +165,5 @@ export function buildGitValidationSchema(
|
|||
RepositoryURLValid: boolean().default(false),
|
||||
AutoUpdate: autoUpdateValidation().nullable(),
|
||||
TLSSkipVerify: boolean().default(false),
|
||||
}).concat(gitAuthValidation(gitCredentials, false));
|
||||
}).concat(gitAuthValidation(gitCredentials, false)) as SchemaOf<GitFormModel>;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ interface Props {
|
|||
onChange(value: string): void;
|
||||
model: RefFieldModel;
|
||||
error?: string;
|
||||
isUrlValid: boolean;
|
||||
isUrlValid?: boolean;
|
||||
stackId?: StackId;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ export function RefSelector({
|
|||
value: string;
|
||||
stackId?: StackId;
|
||||
onChange: (value: string) => void;
|
||||
isUrlValid: boolean;
|
||||
isUrlValid?: boolean;
|
||||
}) {
|
||||
const creds = getAuthentication(model);
|
||||
const payload = {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
export type AutoUpdateMechanism = 'Webhook' | 'Interval';
|
||||
|
||||
export interface AutoUpdateResponse {
|
||||
/* Auto update interval */
|
||||
Interval: string;
|
||||
|
@ -26,6 +25,7 @@ export interface RepoConfigResponse {
|
|||
ConfigFilePath: string;
|
||||
Authentication?: GitAuthenticationResponse;
|
||||
ConfigHash: string;
|
||||
TLSSkipVerify: boolean;
|
||||
}
|
||||
|
||||
export type AutoUpdateModel = {
|
||||
|
@ -52,11 +52,11 @@ export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
|
|||
|
||||
export interface GitFormModel extends GitAuthModel {
|
||||
RepositoryURL: string;
|
||||
RepositoryURLValid: boolean;
|
||||
RepositoryURLValid?: boolean;
|
||||
ComposeFilePathInRepository: string;
|
||||
RepositoryAuthentication: boolean;
|
||||
RepositoryReferenceName?: string;
|
||||
AdditionalFiles: string[];
|
||||
AdditionalFiles?: string[];
|
||||
|
||||
SaveCredential?: boolean;
|
||||
NewCredentialName?: string;
|
||||
|
@ -78,3 +78,31 @@ export interface RelativePathModel {
|
|||
PerDeviceConfigsMatchType?: 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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -28,14 +28,12 @@ export function AppTemplatesList({
|
|||
selectedId,
|
||||
disabledTypes,
|
||||
fixedCategories,
|
||||
hideDuplicate,
|
||||
}: {
|
||||
templates?: TemplateViewModel[];
|
||||
onSelect: (template: TemplateViewModel) => void;
|
||||
selectedId?: TemplateViewModel['Id'];
|
||||
disabledTypes?: Array<TemplateType>;
|
||||
fixedCategories?: Array<string>;
|
||||
hideDuplicate?: boolean;
|
||||
}) {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
|
@ -75,7 +73,6 @@ export function AppTemplatesList({
|
|||
template={template}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedId === template.Id}
|
||||
hideDuplicate={hideDuplicate}
|
||||
/>
|
||||
))}
|
||||
{!templates && <div className="text-muted text-center">Loading...</div>}
|
||||
|
|
|
@ -12,12 +12,10 @@ export function AppTemplatesListItem({
|
|||
template,
|
||||
onSelect,
|
||||
isSelected,
|
||||
hideDuplicate = false,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
onSelect: (template: TemplateViewModel) => void;
|
||||
isSelected: boolean;
|
||||
hideDuplicate?: boolean;
|
||||
}) {
|
||||
const duplicateCustomTemplateType = getCustomTemplateType(template.Type);
|
||||
|
||||
|
@ -30,7 +28,6 @@ export function AppTemplatesListItem({
|
|||
onSelect={() => onSelect(template)}
|
||||
isSelected={isSelected}
|
||||
renderActions={
|
||||
!hideDuplicate &&
|
||||
duplicateCustomTemplateType && (
|
||||
<div className="mr-5 mt-3">
|
||||
<Button
|
||||
|
|
|
@ -14,7 +14,7 @@ export function useAppTemplates() {
|
|||
const registriesQuery = useRegistries();
|
||||
|
||||
return useQuery(
|
||||
'templates',
|
||||
['templates'],
|
||||
() => getTemplatesWithRegistry(registriesQuery.data),
|
||||
{
|
||||
enabled: !!registriesQuery.data,
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { AppTemplate } from '../types';
|
||||
|
||||
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']) {
|
||||
try {
|
||||
const { data } = await axios.post<{ FileContent: string }>(
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
import { Pair } from '../../settings/types';
|
||||
import { Platform } from '../../custom-templates/types';
|
||||
import { Platform } from '../types';
|
||||
|
||||
import {
|
||||
AppTemplate,
|
||||
|
|
|
@ -9,8 +9,9 @@ import { Icon } from '@@/Icon';
|
|||
import { FallbackImage } from '@@/FallbackImage';
|
||||
import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Platform } from '../../custom-templates/types';
|
||||
import { Platform } from '../types';
|
||||
|
||||
type Value = {
|
||||
Id: number | string;
|
||||
|
@ -27,16 +28,24 @@ export function TemplateItem({
|
|||
onSelect,
|
||||
renderActions,
|
||||
isSelected,
|
||||
linkParams,
|
||||
}: {
|
||||
template: Value;
|
||||
typeLabel: string;
|
||||
onSelect: () => void;
|
||||
renderActions: ReactNode;
|
||||
isSelected: boolean;
|
||||
linkParams?: { to: string; params: object };
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<FallbackImage
|
||||
src={template.Logo}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Edit, Plus } from 'lucide-react';
|
|||
import _ from 'lodash';
|
||||
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 { Table } from '@@/datatables';
|
||||
|
@ -22,11 +22,16 @@ export function CustomTemplatesList({
|
|||
onSelect,
|
||||
onDelete,
|
||||
selectedId,
|
||||
templateLinkParams,
|
||||
}: {
|
||||
templates?: CustomTemplate[];
|
||||
onSelect: (template: CustomTemplate['Id']) => void;
|
||||
onSelect?: (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);
|
||||
|
||||
|
@ -67,6 +72,7 @@ export function CustomTemplatesList({
|
|||
onSelect={onSelect}
|
||||
isSelected={template.Id === selectedId}
|
||||
onDelete={onDelete}
|
||||
linkParams={templateLinkParams?.(template)}
|
||||
/>
|
||||
))}
|
||||
{!templates && <div className="text-muted text-center">Loading...</div>}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Edit, Trash2 } from 'lucide-react';
|
|||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
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 { Link } from '@@/Link';
|
||||
|
@ -14,11 +14,13 @@ export function CustomTemplatesListItem({
|
|||
onSelect,
|
||||
onDelete,
|
||||
isSelected,
|
||||
linkParams,
|
||||
}: {
|
||||
template: CustomTemplate;
|
||||
onSelect: (templateId: CustomTemplate['Id']) => void;
|
||||
onSelect?: (templateId: CustomTemplate['Id']) => void;
|
||||
onDelete: (templateId: CustomTemplate['Id']) => void;
|
||||
isSelected: boolean;
|
||||
linkParams?: { to: string; params: object };
|
||||
}) {
|
||||
const { isAdmin, user } = useCurrentUser();
|
||||
const isEditAllowed = isAdmin || template.CreatedByUserId === user.Id;
|
||||
|
@ -27,8 +29,9 @@ export function CustomTemplatesListItem({
|
|||
<TemplateItem
|
||||
template={template}
|
||||
typeLabel={getTypeLabel(template.Type)}
|
||||
onSelect={() => onSelect(template.Id)}
|
||||
onSelect={() => onSelect?.(template.Id)}
|
||||
isSelected={isSelected}
|
||||
linkParams={linkParams}
|
||||
renderActions={
|
||||
<div className="mr-4 mt-3">
|
||||
{isEditAllowed && (
|
||||
|
@ -36,13 +39,14 @@ export function CustomTemplatesListItem({
|
|||
<Button
|
||||
as={Link}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="secondary"
|
||||
props={{
|
||||
to: '.edit',
|
||||
params: {
|
||||
id: template.Id,
|
||||
templateId: template.Id,
|
||||
},
|
||||
}}
|
||||
icon={Edit}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,15 +1,10 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { ResourceControlResponse } from '../access-control/types';
|
||||
import { RepoConfigResponse } from '../gitops/types';
|
||||
|
||||
import { VariableDefinition } from './components/CustomTemplatesVariablesDefinitionField';
|
||||
|
||||
export enum Platform {
|
||||
LINUX = 1,
|
||||
WINDOWS,
|
||||
}
|
||||
import { ResourceControlResponse } from '../../access-control/types';
|
||||
import { RepoConfigResponse } from '../../gitops/types';
|
||||
import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { Platform } from '../types';
|
||||
|
||||
export type CustomTemplate = {
|
||||
Id: number;
|
||||
|
@ -89,6 +84,9 @@ export type CustomTemplate = {
|
|||
* @example false
|
||||
*/
|
||||
IsComposeFormat: boolean;
|
||||
|
||||
/** EdgeTemplate indicates if this template purpose for Edge Stack */
|
||||
EdgeTemplate: boolean;
|
||||
};
|
||||
|
||||
export type CustomTemplateFileContent = {
|
|
@ -0,0 +1,4 @@
|
|||
export enum Platform {
|
||||
LINUX = 1,
|
||||
WINDOWS,
|
||||
}
|
|
@ -55,7 +55,7 @@ export function EdgeComputeSidebar() {
|
|||
)}
|
||||
<SidebarParent
|
||||
icon={Edit}
|
||||
label="Templates"
|
||||
label="Edge Templates"
|
||||
to="edge.templates"
|
||||
data-cy="edgeSidebar-templates"
|
||||
>
|
||||
|
@ -66,12 +66,12 @@ export function EdgeComputeSidebar() {
|
|||
isSubMenu
|
||||
data-cy="edgeSidebar-appTemplates"
|
||||
/>
|
||||
{/* <SidebarItem
|
||||
<SidebarItem
|
||||
label="Custom"
|
||||
to="edge.templates.custom"
|
||||
isSubMenu
|
||||
data-cy="edgeSidebar-customTemplates"
|
||||
/> */}
|
||||
/>
|
||||
</SidebarParent>
|
||||
</SidebarSection>
|
||||
);
|
||||
|
|
|
@ -179,7 +179,7 @@ module.exports = {
|
|||
},
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
aggregateTimeout: 500,
|
||||
aggregateTimeout: 200,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
Loading…
Reference in New Issue