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
|
@ -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,20 +22,22 @@ export function FormActions({
|
|||
'data-cy': dataCy,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
loadingText={loadingText}
|
||||
isLoading={isLoading}
|
||||
disabled={!isValid}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
{submitLabel}
|
||||
</LoadingButton>
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
loadingText={loadingText}
|
||||
isLoading={isLoading}
|
||||
disabled={!isValid}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
{submitLabel}
|
||||
</LoadingButton>
|
||||
|
||||
{children}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
|
142
app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx
Normal file
142
app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { useState } from 'react';
|
||||
import sanitize from 'sanitize-html';
|
||||
|
||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { useCustomTemplateFileMutation } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
||||
import {
|
||||
CustomTemplatesVariablesField,
|
||||
VariablesFieldValue,
|
||||
getVariablesFieldDefaultValues,
|
||||
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
|
||||
export function TemplateFieldset({
|
||||
value: selectedTemplate,
|
||||
onChange,
|
||||
onChangeFile,
|
||||
}: {
|
||||
value: CustomTemplate | undefined;
|
||||
onChange: (value?: CustomTemplate) => void;
|
||||
onChangeFile: (value: string) => void;
|
||||
}) {
|
||||
const fetchFileMutation = useCustomTemplateFileMutation();
|
||||
const [templateFile, setTemplateFile] = useState('');
|
||||
const templatesQuery = useCustomTemplates({
|
||||
select: (templates) =>
|
||||
templates.filter((template) => template.EdgeTemplate),
|
||||
});
|
||||
|
||||
const [variableValues, setVariableValues] = useState<VariablesFieldValue>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateSelector
|
||||
value={selectedTemplate?.Id}
|
||||
onChange={handleChangeTemplate}
|
||||
/>
|
||||
{selectedTemplate && (
|
||||
<>
|
||||
{selectedTemplate.Note && (
|
||||
<div>
|
||||
<div className="col-sm-12 form-section-title"> Information </div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<div
|
||||
className="template-note"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize(selectedTemplate.Note),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomTemplatesVariablesField
|
||||
onChange={(value) => {
|
||||
setVariableValues(value);
|
||||
onChangeFile(
|
||||
renderTemplate(templateFile, value, selectedTemplate.Variables)
|
||||
);
|
||||
}}
|
||||
value={variableValues}
|
||||
definitions={selectedTemplate.Variables}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChangeTemplate(templateId: CustomTemplate['Id'] | undefined) {
|
||||
const selectedTemplate = templatesQuery.data?.find(
|
||||
(template) => template.Id === templateId
|
||||
);
|
||||
if (!selectedTemplate) {
|
||||
setVariableValues([]);
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchFileMutation.mutate(
|
||||
{ id: selectedTemplate.Id, git: !!selectedTemplate.GitConfig },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setTemplateFile(data);
|
||||
onChangeFile(
|
||||
renderTemplate(
|
||||
data,
|
||||
getVariablesFieldDefaultValues(selectedTemplate.Variables),
|
||||
selectedTemplate.Variables
|
||||
)
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
setVariableValues(
|
||||
selectedTemplate
|
||||
? getVariablesFieldDefaultValues(selectedTemplate.Variables)
|
||||
: []
|
||||
);
|
||||
onChange(selectedTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
function TemplateSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: CustomTemplate['Id'] | undefined;
|
||||
onChange: (value: CustomTemplate['Id'] | undefined) => void;
|
||||
}) {
|
||||
const templatesQuery = useCustomTemplates({
|
||||
select: (templates) =>
|
||||
templates.filter((template) => template.EdgeTemplate),
|
||||
});
|
||||
|
||||
if (!templatesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl label="Template" inputId="stack_template">
|
||||
<PortainerSelect
|
||||
placeholder="Select an Edge stack template"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={templatesQuery.data.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
value: template.Id,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
function handleChange(value: CustomTemplate['Id']) {
|
||||
onChange(value);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
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';
|
|
@ -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
|
||||
.filter(([type, value]) => type === 'name' && value)
|
||||
.map(([, value]) => ({
|
||||
name: value,
|
||||
label: '',
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
}));
|
||||
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 = {
|
4
app/react/portainer/templates/types.ts
Normal file
4
app/react/portainer/templates/types.ts
Normal file
|
@ -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>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue