1
0
Fork 0
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:
Chaim Lev-Ari 2023-11-15 10:45:07 +02:00 committed by GitHub
parent a0f583a17d
commit 68950fbb24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 2047 additions and 334 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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