mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +02:00
feat(edge/templates): introduce custom templates [EE-6208] (#10561)
This commit is contained in:
parent
a0f583a17d
commit
68950fbb24
81 changed files with 2047 additions and 334 deletions
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
170
app/react/edge/templates/custom-templates/EditView/InnerForm.tsx
Normal file
170
app/react/edge/templates/custom-templates/EditView/InnerForm.tsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
import { Form, useFormikContext } from 'formik';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields';
|
||||
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import {
|
||||
getTemplateVariables,
|
||||
intersectVariables,
|
||||
isTemplateVariablesEnabled,
|
||||
} from '@/react/portainer/custom-templates/components/utils';
|
||||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||
|
||||
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function InnerForm({
|
||||
isLoading,
|
||||
isEditorReadonly,
|
||||
gitFileContent,
|
||||
gitFileError,
|
||||
refreshGitFile,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isEditorReadonly: boolean;
|
||||
gitFileContent?: string;
|
||||
gitFileError?: string;
|
||||
refreshGitFile: () => void;
|
||||
}) {
|
||||
const {
|
||||
values,
|
||||
initialValues,
|
||||
setFieldValue,
|
||||
errors,
|
||||
isValid,
|
||||
setFieldError,
|
||||
isSubmitting,
|
||||
dirty,
|
||||
setValues,
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
usePreventExit(
|
||||
initialValues.FileContent,
|
||||
values.FileContent,
|
||||
!isEditorReadonly && !isSubmitting
|
||||
);
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<CommonFields
|
||||
values={values}
|
||||
onChange={(newValues) =>
|
||||
setValues((values) => ({ ...values, ...newValues }))
|
||||
}
|
||||
errors={errors}
|
||||
/>
|
||||
|
||||
<PlatformField
|
||||
value={values.Platform}
|
||||
onChange={(value) => setFieldValue('Platform', value)}
|
||||
/>
|
||||
|
||||
<TemplateTypeSelector
|
||||
value={values.Type}
|
||||
onChange={(value) => setFieldValue('Type', value)}
|
||||
/>
|
||||
|
||||
<WebEditorForm
|
||||
id="edit-custom-template-editor"
|
||||
value={gitFileContent || values.FileContent}
|
||||
onChange={handleChangeFileContent}
|
||||
yaml
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
error={errors.FileContent}
|
||||
readonly={isEditorReadonly}
|
||||
>
|
||||
<p>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</WebEditorForm>
|
||||
|
||||
{values.Git && (
|
||||
<>
|
||||
<GitForm
|
||||
value={values.Git}
|
||||
onChange={(newValues) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
// set ! for values.Git because this callback will only be called when it's defined (see L94)
|
||||
Git: { ...values.Git!, ...newValues },
|
||||
}))
|
||||
}
|
||||
errors={typeof errors.Git === 'object' ? errors.Git : undefined}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<Button color="light" icon={RefreshCw} onClick={refreshGitFile}>
|
||||
Reload custom template
|
||||
</Button>
|
||||
</div>
|
||||
{gitFileError && (
|
||||
<div className="col-sm-12">
|
||||
<FormError>
|
||||
Custom template could not be loaded, {gitFileError}.
|
||||
</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isTemplateVariablesEnabled && (
|
||||
<CustomTemplatesVariablesDefinitionField
|
||||
value={values.Variables}
|
||||
onChange={(values) => setFieldValue('Variables', values)}
|
||||
isVariablesNamesFromParent={!isEditorReadonly}
|
||||
errors={errors.Variables}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormActions
|
||||
isLoading={isLoading}
|
||||
isValid={isValid && dirty}
|
||||
loadingText="Updating custom template..."
|
||||
submitLabel="Update custom template"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
|
||||
function handleChangeFileContent(value: string) {
|
||||
setFieldValue(
|
||||
'FileContent',
|
||||
value,
|
||||
isTemplateVariablesEnabled ? !value : true
|
||||
);
|
||||
parseTemplate(value);
|
||||
}
|
||||
|
||||
function parseTemplate(value: string) {
|
||||
if (!isTemplateVariablesEnabled || value === '') {
|
||||
setFieldValue('Variables', []);
|
||||
return;
|
||||
}
|
||||
|
||||
const [variables, validationError] = getTemplateVariables(value);
|
||||
|
||||
setFieldError(
|
||||
'FileContent',
|
||||
validationError ? `Template invalid: ${validationError}` : undefined
|
||||
);
|
||||
if (variables) {
|
||||
setFieldValue(
|
||||
'Variables',
|
||||
intersectVariables(values.Variables, variables)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { EditView } from './EditView';
|
13
app/react/edge/templates/custom-templates/EditView/types.ts
Normal file
13
app/react/edge/templates/custom-templates/EditView/types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { Platform } from '@/react/portainer/templates/types';
|
||||
import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
export interface FormValues extends CommonFieldsValues {
|
||||
Platform: Platform;
|
||||
Type: StackType;
|
||||
FileContent: string;
|
||||
Git?: GitFormModel;
|
||||
Variables: DefinitionFieldValues;
|
||||
}
|
|
@ -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';
|
Loading…
Add table
Add a link
Reference in a new issue