mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(edge/stacks): migrate create view to react [EE-2223] (#11575)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
This commit is contained in:
parent
f22aed34b5
commit
8a81d95253
64 changed files with 1878 additions and 1005 deletions
133
app/react/edge/edge-stacks/CreateView/CreateForm.tsx
Normal file
133
app/react/edge/edge-stacks/CreateView/CreateForm.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
|
||||
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { getDefaultValues as getEnvVarDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { DeploymentType } from '../types';
|
||||
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||
|
||||
import { InnerForm } from './InnerForm';
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './CreateForm.validation';
|
||||
import { Values as TemplateValues } from './TemplateFieldset/types';
|
||||
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
|
||||
import { useTemplateParams } from './useTemplateParams';
|
||||
import { useCreate } from './useCreate';
|
||||
|
||||
export function CreateForm() {
|
||||
const [webhookId] = useState(() => createWebhookId());
|
||||
|
||||
const [templateParams, setTemplateParams] = useTemplateParams();
|
||||
const templateQuery = useTemplate(templateParams.type, templateParams.id);
|
||||
|
||||
const validation = useValidation(templateQuery);
|
||||
const mutation = useCreate({
|
||||
webhookId,
|
||||
template: templateQuery.customTemplate || templateQuery.appTemplate,
|
||||
templateType: templateParams.type,
|
||||
});
|
||||
|
||||
if (
|
||||
templateParams.id &&
|
||||
!(templateQuery.customTemplate || templateQuery.appTemplate)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
deploymentType: DeploymentType.Compose,
|
||||
envVars: [],
|
||||
privateRegistryId: 0,
|
||||
prePullImage: false,
|
||||
retryDeploy: false,
|
||||
staggerConfig: getDefaultStaggerConfig(),
|
||||
method: templateParams.id ? 'template' : 'editor',
|
||||
git: toGitFormModel(),
|
||||
relativePath: getDefaultRelativePathModel(),
|
||||
enableWebhook: false,
|
||||
fileContent: '',
|
||||
templateValues: getTemplateValues(templateParams.type, template),
|
||||
useManifestNamespaces: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={mutation.onSubmit}
|
||||
validationSchema={validation}
|
||||
>
|
||||
<InnerForm
|
||||
webhookId={webhookId}
|
||||
isLoading={mutation.isLoading}
|
||||
onChangeTemplate={setTemplateParams}
|
||||
/>
|
||||
</Formik>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTemplateValues(
|
||||
type: 'custom' | 'app' | undefined,
|
||||
template: TemplateViewModel | CustomTemplate | undefined
|
||||
): TemplateValues {
|
||||
if (!type) {
|
||||
return getInitialTemplateValues();
|
||||
}
|
||||
|
||||
if (type === 'custom') {
|
||||
const customTemplate = template as CustomTemplate;
|
||||
return {
|
||||
templateId: customTemplate.Id,
|
||||
type,
|
||||
variables: getVariablesFieldDefaultValues(customTemplate.Variables),
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
|
||||
const appTemplate = template as TemplateViewModel;
|
||||
|
||||
return {
|
||||
templateId: appTemplate.Id,
|
||||
type,
|
||||
variables: [],
|
||||
envVars: getEnvVarDefaultValues(appTemplate.Env),
|
||||
};
|
||||
}
|
||||
|
||||
function useTemplate(
|
||||
type: 'app' | 'custom' | undefined,
|
||||
id: number | undefined
|
||||
) {
|
||||
const customTemplateQuery = useCustomTemplate(id, {
|
||||
enabled: !!id && type === 'custom',
|
||||
});
|
||||
const appTemplateQuery = useAppTemplate(id, {
|
||||
enabled: !!id && type === 'app',
|
||||
});
|
||||
|
||||
return {
|
||||
appTemplate: appTemplateQuery.data,
|
||||
customTemplate: customTemplateQuery.data,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
SchemaOf,
|
||||
array,
|
||||
boolean,
|
||||
lazy,
|
||||
mixed,
|
||||
number,
|
||||
object,
|
||||
string,
|
||||
} from 'yup';
|
||||
import { useMemo } from 'react';
|
||||
import Lazy from 'yup/lib/Lazy';
|
||||
|
||||
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 { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { file } from '@@/form-components/yup-file-validation';
|
||||
|
||||
import { DeploymentType } from '../types';
|
||||
import { staggerConfigValidation } from '../components/StaggerFieldset';
|
||||
|
||||
import { FormValues, Method } from './types';
|
||||
import { templateFieldsetValidation } from './TemplateFieldset/validation';
|
||||
import { useNameValidation } from './NameField';
|
||||
|
||||
export function useValidation({
|
||||
appTemplate,
|
||||
customTemplate,
|
||||
}: {
|
||||
appTemplate: TemplateViewModel | undefined;
|
||||
customTemplate: CustomTemplate | undefined;
|
||||
}): Lazy<SchemaOf<FormValues>> {
|
||||
const { user } = useCurrentUser();
|
||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||
const nameValidation = useNameValidation();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
lazy((values: FormValues) =>
|
||||
object({
|
||||
method: mixed<Method>()
|
||||
.oneOf(['editor', 'upload', 'repository', 'template'])
|
||||
.required(),
|
||||
name: nameValidation(values.groupIds),
|
||||
groupIds: array(number().required())
|
||||
.required()
|
||||
.min(1, 'At least one Edge group is required'),
|
||||
deploymentType: mixed<DeploymentType>()
|
||||
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
|
||||
.required(),
|
||||
envVars: envVarValidation(),
|
||||
privateRegistryId: number().default(0),
|
||||
prePullImage: boolean().default(false),
|
||||
retryDeploy: boolean().default(false),
|
||||
enableWebhook: boolean().default(false),
|
||||
staggerConfig: staggerConfigValidation(),
|
||||
fileContent: string()
|
||||
.default('')
|
||||
.when('method', {
|
||||
is: 'editor',
|
||||
then: (schema) => schema.required('Config file is required'),
|
||||
}),
|
||||
file: file().when('method', {
|
||||
is: 'upload',
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
templateValues: templateFieldsetValidation({
|
||||
customVariablesDefinitions: customTemplate?.Variables || [],
|
||||
envVarDefinitions: appTemplate?.Env || [],
|
||||
}),
|
||||
git: mixed().when('method', {
|
||||
is: 'repository',
|
||||
then: buildGitValidationSchema(
|
||||
gitCredentialsQuery.data || [],
|
||||
!!customTemplate
|
||||
),
|
||||
}) as SchemaOf<GitFormModel>,
|
||||
relativePath: relativePathValidation(),
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
})
|
||||
),
|
||||
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
|
||||
);
|
||||
}
|
20
app/react/edge/edge-stacks/CreateView/CreateView.tsx
Normal file
20
app/react/edge/edge-stacks/CreateView/CreateView.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { CreateForm } from './CreateForm';
|
||||
|
||||
export function CreateView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Edge stack"
|
||||
breadcrumbs={[
|
||||
{ label: 'Edge Stacks', link: 'edge.stacks' },
|
||||
'Create Edge stack',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<CreateForm />
|
||||
</>
|
||||
);
|
||||
}
|
43
app/react/edge/edge-stacks/CreateView/DeploymentOptions.tsx
Normal file
43
app/react/edge/edge-stacks/CreateView/DeploymentOptions.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function DeploymentOptions({
|
||||
setFieldValue,
|
||||
values,
|
||||
}: {
|
||||
values: FormValues;
|
||||
setFieldValue: <T>(field: string, value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.prePullImage}
|
||||
name="prePullImage"
|
||||
label="Pre-pull images"
|
||||
tooltip="When enabled, the image will be pre-pulled before deployment is started. This is useful in scenarios where the image download may be delayed or intermittent and would subsequently cause the deployment to fail"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||
data-cy="pre-pull-images-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={values.retryDeploy}
|
||||
name="retryDeploy"
|
||||
label="Retry deployment"
|
||||
tooltip="When enabled, this will allow the edge agent to retry deployment if failed to deploy initially"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||
data-cy="retry-deployment-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
157
app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx
Normal file
157
app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import {
|
||||
editor,
|
||||
git,
|
||||
edgeStackTemplate,
|
||||
upload,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||
|
||||
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
|
||||
import { useRenderTemplate } from './useRenderTemplate';
|
||||
import { DockerFormValues } from './types';
|
||||
|
||||
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||
|
||||
export function DockerComposeForm({
|
||||
webhookId,
|
||||
onChangeTemplate,
|
||||
}: {
|
||||
webhookId: string;
|
||||
onChangeTemplate: ({
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
}) => void;
|
||||
}) {
|
||||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||
const { method } = values;
|
||||
|
||||
const template = useRenderTemplate(values.templateValues, setValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection title="Build Method">
|
||||
<BoxSelector
|
||||
options={buildMethods}
|
||||
onChange={(value) => handleChange({ method: value })}
|
||||
value={method}
|
||||
radioName="method"
|
||||
slim
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{method === edgeStackTemplate.value && (
|
||||
<TemplateFieldset
|
||||
values={values.templateValues}
|
||||
setValues={(templateAction) =>
|
||||
setValues((values) => {
|
||||
const templateValues = applySetStateAction(
|
||||
templateAction,
|
||||
values.templateValues
|
||||
);
|
||||
onChangeTemplate({
|
||||
id: templateValues.templateId,
|
||||
type: templateValues.type,
|
||||
});
|
||||
|
||||
return {
|
||||
...values,
|
||||
templateValues,
|
||||
};
|
||||
})
|
||||
}
|
||||
errors={errors?.templateValues}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(method === editor.value ||
|
||||
(method === edgeStackTemplate.value && template)) && (
|
||||
<WebEditorForm
|
||||
id="stack-creation-editor"
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
yaml
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
error={errors?.fileContent}
|
||||
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
|
||||
data-cy="stack-creation-editor"
|
||||
>
|
||||
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>
|
||||
.
|
||||
</WebEditorForm>
|
||||
)}
|
||||
|
||||
{method === upload.value && (
|
||||
<FileUploadForm
|
||||
value={values.file}
|
||||
onChange={(File) => handleChange({ file: File })}
|
||||
required
|
||||
description="You can upload a Compose file from your computer."
|
||||
data-cy="stack-creation-file-upload"
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === git.value && (
|
||||
<>
|
||||
<GitForm
|
||||
errors={errors?.git}
|
||||
value={values.git}
|
||||
onChange={(gitValues) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
git: {
|
||||
...values.git,
|
||||
...gitValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
|
||||
<FormSection title="Advanced configurations">
|
||||
<RelativePathFieldset
|
||||
values={values.relativePath}
|
||||
gitModel={values.git}
|
||||
onChange={(relativePath) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
relativePath: {
|
||||
...values.relativePath,
|
||||
...relativePath,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormSection>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(newValues: Partial<DockerFormValues>) {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
}
|
||||
}
|
139
app/react/edge/edge-stacks/CreateView/InnerForm.tsx
Normal file
139
app/react/edge/edge-stacks/CreateView/InnerForm.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { Form, useFormikContext } from 'formik';
|
||||
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
|
||||
import { EdgeGroupsSelector } from '../components/EdgeGroupsSelector';
|
||||
import { EdgeStackDeploymentTypeSelector } from '../components/EdgeStackDeploymentTypeSelector';
|
||||
import { StaggerFieldset } from '../components/StaggerFieldset';
|
||||
import { PrivateRegistryFieldsetWrapper } from '../ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper';
|
||||
import { useValidateEnvironmentTypes } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
|
||||
import { DeploymentType } from '../types';
|
||||
|
||||
import { DockerComposeForm } from './DockerComposeForm';
|
||||
import { KubeFormValues, KubeManifestForm } from './KubeManifestForm';
|
||||
import { NameField } from './NameField';
|
||||
import { WebhookSwitch } from './WebhookSwitch';
|
||||
import { FormValues } from './types';
|
||||
import { DeploymentOptions } from './DeploymentOptions';
|
||||
|
||||
export function InnerForm({
|
||||
webhookId,
|
||||
isLoading,
|
||||
onChangeTemplate,
|
||||
}: {
|
||||
webhookId: string;
|
||||
isLoading: boolean;
|
||||
onChangeTemplate: ({
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
}) => void;
|
||||
}) {
|
||||
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
|
||||
useFormikContext<FormValues>();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<NameField
|
||||
onChange={(value) => setFieldValue('name', value)}
|
||||
value={values.name}
|
||||
errors={errors.name}
|
||||
/>
|
||||
|
||||
<EdgeGroupsSelector
|
||||
value={values.groupIds}
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
{hasKubeEndpoint && hasDockerEndpoint && (
|
||||
<TextTip>
|
||||
There are no available deployment types when there is more than one
|
||||
type of environment in your edge group selection (e.g. Kubernetes and
|
||||
Docker environments). Please select edge groups that have environments
|
||||
of the same type.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<EdgeStackDeploymentTypeSelector
|
||||
value={values.deploymentType}
|
||||
hasDockerEndpoint={hasDockerEndpoint}
|
||||
hasKubeEndpoint={hasKubeEndpoint}
|
||||
onChange={(value) => setFieldValue('deploymentType', value)}
|
||||
/>
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<DockerComposeForm
|
||||
webhookId={webhookId}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Kubernetes && (
|
||||
<KubeManifestForm
|
||||
values={values as KubeFormValues}
|
||||
webhookId={webhookId}
|
||||
errors={errors}
|
||||
setValues={(kubeValues) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...applySetStateAction(kubeValues, values as KubeFormValues),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.method !== 'repository' && (
|
||||
<WebhookSwitch
|
||||
onChange={(value) => setFieldValue('enableWebhook', value)}
|
||||
value={values.enableWebhook}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<EnvironmentVariablesPanel
|
||||
values={values.envVars}
|
||||
onChange={(value) => setFieldValue('envVars', value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PrivateRegistryFieldsetWrapper
|
||||
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||
value={values.privateRegistryId}
|
||||
values={{ fileContent: values.fileContent, file: values.file }}
|
||||
error={errors.privateRegistryId}
|
||||
onFieldError={(message) => setFieldError('privateRegistryId', message)}
|
||||
isGit={values.method === 'repository'}
|
||||
/>
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<DeploymentOptions values={values} setFieldValue={setFieldValue} />
|
||||
)}
|
||||
|
||||
<StaggerFieldset
|
||||
isEdit={false}
|
||||
values={values.staggerConfig}
|
||||
onChange={(value) => setFieldValue('staggerConfig', value)}
|
||||
/>
|
||||
|
||||
<FormActions
|
||||
data-cy="edgeStackCreate-createStackButton"
|
||||
submitLabel="Deploy the stack"
|
||||
loadingText="Deployment in progress..."
|
||||
isValid={isValid}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
144
app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
Normal file
144
app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { SetStateAction } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import {
|
||||
editor,
|
||||
git,
|
||||
upload,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
const buildMethods = [editor, upload, git] as const;
|
||||
|
||||
export interface KubeFormValues {
|
||||
method: 'editor' | 'upload' | 'repository' | 'template';
|
||||
useManifestNamespaces: boolean;
|
||||
fileContent: string;
|
||||
file?: File;
|
||||
git: GitFormModel;
|
||||
}
|
||||
|
||||
export function KubeManifestForm({
|
||||
errors,
|
||||
values,
|
||||
setValues,
|
||||
webhookId,
|
||||
}: {
|
||||
errors?: FormikErrors<KubeFormValues>;
|
||||
values: KubeFormValues;
|
||||
setValues: (values: SetStateAction<KubeFormValues>) => void;
|
||||
webhookId: string;
|
||||
}) {
|
||||
const { method } = values;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Use namespace(s) specified from manifest"
|
||||
tooltip="If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment"
|
||||
checked={values.useManifestNamespaces}
|
||||
onChange={(value) =>
|
||||
handleChange({
|
||||
useManifestNamespaces: value,
|
||||
})
|
||||
}
|
||||
data-cy="use-manifest-namespaces-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSection title="Build Method">
|
||||
<BoxSelector
|
||||
options={buildMethods}
|
||||
onChange={(value) => handleChange({ method: value })}
|
||||
value={method}
|
||||
radioName="method"
|
||||
slim
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{method === editor.value && (
|
||||
<WebEditorForm
|
||||
id="stack-creation-editor"
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
yaml
|
||||
placeholder="Define or paste the content of your manifest file here"
|
||||
error={errors?.fileContent}
|
||||
data-cy="stack-creation-editor"
|
||||
>
|
||||
<KubeDeployDescription />
|
||||
</WebEditorForm>
|
||||
)}
|
||||
|
||||
{method === upload.value && (
|
||||
<FileUploadForm
|
||||
value={values.file}
|
||||
onChange={(file) => handleChange({ file })}
|
||||
required
|
||||
description="You can upload a Manifest file from your computer."
|
||||
data-cy="stack-creation-file-upload"
|
||||
>
|
||||
<KubeDeployDescription />
|
||||
</FileUploadForm>
|
||||
)}
|
||||
|
||||
{method === git.value && (
|
||||
<GitForm
|
||||
errors={errors?.git}
|
||||
value={values.git}
|
||||
onChange={(gitValues) =>
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
git: {
|
||||
...values.git,
|
||||
...gitValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(newValues: Partial<KubeFormValues>) {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function KubeDeployDescription() {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Templates allow deploying any kind of Kubernetes resource (Deployment,
|
||||
Secret, ConfigMap...)
|
||||
</div>
|
||||
<div>
|
||||
You can get more information about Kubernetes file format in the
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,12 +1,17 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { SchemaOf, string } from 'yup';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
import { EdgeStack } from '../types';
|
||||
import { useEdgeStacks } from '../queries/useEdgeStacks';
|
||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
||||
import { EdgeGroup } from '../../edge-groups/types';
|
||||
|
||||
export function NameField({
|
||||
onChange,
|
||||
|
@ -24,7 +29,7 @@ export function NameField({
|
|||
onChange={(e) => onChange(e.target.value)}
|
||||
value={value}
|
||||
required
|
||||
data-cy="edge-stack-create-name-input"
|
||||
data-cy="edgeStackCreate-nameInput"
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
@ -49,3 +54,23 @@ export function nameValidation(
|
|||
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function useNameValidation() {
|
||||
const edgeStacksQuery = useEdgeStacks();
|
||||
const edgeGroupsQuery = useEdgeGroups({
|
||||
select: (groups) =>
|
||||
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
|
||||
});
|
||||
const edgeGroupsType = edgeGroupsQuery.data;
|
||||
|
||||
return useMemo(
|
||||
() => (groupIds: Array<EdgeGroup['Id']>) =>
|
||||
nameValidation(
|
||||
edgeStacksQuery.data || [],
|
||||
groupIds
|
||||
.flatMap((g) => edgeGroupsType?.[g])
|
||||
?.includes(EnvironmentType.EdgeAgentOnDocker)
|
||||
),
|
||||
[edgeGroupsType, edgeStacksQuery.data]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { EnvVarType } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import {
|
||||
EnvVarType,
|
||||
TemplateViewModel,
|
||||
} from '@/react/portainer/templates/app-templates/view-model';
|
||||
AppTemplate,
|
||||
TemplateType,
|
||||
} from '@/react/portainer/templates/app-templates/types';
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||
|
||||
test('renders AppTemplateFieldset component', () => {
|
||||
test('renders AppTemplateFieldset component', async () => {
|
||||
const testedEnv = {
|
||||
name: 'VAR2',
|
||||
label: 'Variable 2',
|
||||
|
@ -27,27 +31,44 @@ test('renders AppTemplateFieldset component', () => {
|
|||
testedEnv,
|
||||
];
|
||||
const template = {
|
||||
Note: 'This is a template note',
|
||||
Env: env,
|
||||
} as TemplateViewModel;
|
||||
id: 1,
|
||||
note: 'This is a template note',
|
||||
env,
|
||||
type: TemplateType.ComposeStack,
|
||||
categories: ['edge'],
|
||||
title: 'Template title',
|
||||
description: 'Template description',
|
||||
administrator_only: false,
|
||||
image: 'template-image',
|
||||
repository: {
|
||||
url: '',
|
||||
stackfile: '',
|
||||
},
|
||||
} satisfies AppTemplate;
|
||||
|
||||
const values: Record<string, string> = {
|
||||
VAR1: 'value1',
|
||||
VAR2: 'value2',
|
||||
};
|
||||
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<AppTemplateFieldset
|
||||
template={template}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
/>
|
||||
server.use(
|
||||
http.get('/api/templates', () =>
|
||||
HttpResponse.json({ version: '3', templates: [template] })
|
||||
),
|
||||
http.get('/api/registries', () => HttpResponse.json([]))
|
||||
);
|
||||
|
||||
const templateNoteElement = screen.getByText('This is a template note');
|
||||
expect(templateNoteElement).toBeInTheDocument();
|
||||
const onChange = vi.fn();
|
||||
const Wrapped = withTestQueryProvider(AppTemplateFieldset);
|
||||
render(
|
||||
<Wrapped templateId={template.id} values={values} onChange={onChange} />
|
||||
);
|
||||
|
||||
screen.debug();
|
||||
|
||||
await expect(
|
||||
screen.findByText('This is a template note')
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
|
||||
exact: false,
|
||||
|
|
|
@ -1,23 +1,31 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||
import {
|
||||
EnvVarsFieldset,
|
||||
EnvVarsValue,
|
||||
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||
|
||||
export function AppTemplateFieldset({
|
||||
template,
|
||||
templateId,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
template: TemplateViewModel;
|
||||
templateId: TemplateViewModel['Id'];
|
||||
values: EnvVarsValue;
|
||||
onChange: (value: EnvVarsValue) => void;
|
||||
errors?: FormikErrors<EnvVarsValue>;
|
||||
}) {
|
||||
const templateQuery = useAppTemplate(templateId);
|
||||
if (!templateQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = templateQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateNote note={template.Note} />
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
|
||||
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
|
@ -10,13 +11,21 @@ export function CustomTemplateFieldset({
|
|||
errors,
|
||||
onChange,
|
||||
values,
|
||||
template,
|
||||
templateId,
|
||||
}: {
|
||||
values: Values['variables'];
|
||||
onChange: (values: Values['variables']) => void;
|
||||
errors: ArrayError<Values['variables']> | undefined;
|
||||
template: CustomTemplate;
|
||||
templateId: CustomTemplate['Id'];
|
||||
}) {
|
||||
const templateQuery = useCustomTemplate(templateId);
|
||||
|
||||
if (!templateQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = templateQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateNote note={template.Note} />
|
||||
|
|
|
@ -1,49 +1,38 @@
|
|||
import { SetStateAction, useEffect, useState } from 'react';
|
||||
import { SetStateAction } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||
|
||||
import { TemplateSelector } from './TemplateSelector';
|
||||
import { SelectedTemplateValue, Values } from './types';
|
||||
import { Values } from './types';
|
||||
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
|
||||
import { AppTemplateFieldset } from './AppTemplateFieldset';
|
||||
|
||||
export function TemplateFieldset({
|
||||
values: initialValues,
|
||||
setValues: setInitialValues,
|
||||
values,
|
||||
setValues,
|
||||
errors,
|
||||
}: {
|
||||
errors?: FormikErrors<Values>;
|
||||
values: Values;
|
||||
setValues: (values: SetStateAction<Values>) => void;
|
||||
}) {
|
||||
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialValues.type !== values.type ||
|
||||
initialValues.template?.Id !== values.template?.Id
|
||||
) {
|
||||
setControlledValues(initialValues);
|
||||
}
|
||||
}, [initialValues, values]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateSelector
|
||||
error={
|
||||
typeof errors?.template === 'string' ? errors?.template : undefined
|
||||
}
|
||||
error={errors?.templateId}
|
||||
value={values}
|
||||
onChange={handleChangeTemplate}
|
||||
/>
|
||||
{values.template && (
|
||||
{values.templateId && (
|
||||
<>
|
||||
{values.type === 'custom' && (
|
||||
<CustomTemplateFieldset
|
||||
template={values.template}
|
||||
templateId={values.templateId}
|
||||
values={values.variables}
|
||||
onChange={(variables) =>
|
||||
setValues((values) => ({ ...values, variables }))
|
||||
|
@ -54,7 +43,7 @@ export function TemplateFieldset({
|
|||
|
||||
{values.type === 'app' && (
|
||||
<AppTemplateFieldset
|
||||
template={values.template}
|
||||
templateId={values.templateId}
|
||||
values={values.envVars}
|
||||
onChange={(envVars) =>
|
||||
setValues((values) => ({ ...values, envVars }))
|
||||
|
@ -67,36 +56,36 @@ export function TemplateFieldset({
|
|||
</>
|
||||
);
|
||||
|
||||
function setValues(values: SetStateAction<Values>) {
|
||||
setControlledValues(values);
|
||||
setInitialValues(values);
|
||||
}
|
||||
|
||||
function handleChangeTemplate(value?: SelectedTemplateValue) {
|
||||
function handleChangeTemplate(
|
||||
template: TemplateViewModel | CustomTemplate | undefined,
|
||||
type: 'app' | 'custom' | undefined
|
||||
): void {
|
||||
setValues(() => {
|
||||
if (!value || !value.type || !value.template) {
|
||||
if (!template || !type) {
|
||||
return {
|
||||
type: undefined,
|
||||
template: undefined,
|
||||
templateId: undefined,
|
||||
variables: [],
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (value.type === 'app') {
|
||||
if (type === 'app') {
|
||||
return {
|
||||
template: value.template,
|
||||
type: value.type,
|
||||
templateId: template.Id,
|
||||
type,
|
||||
variables: [],
|
||||
envVars: getAppVariablesDefaultValues(value.template.Env || []),
|
||||
envVars: getAppVariablesDefaultValues(
|
||||
(template as TemplateViewModel).Env || []
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
template: value.template,
|
||||
type: value.type,
|
||||
templateId: template.Id,
|
||||
type,
|
||||
variables: getVariablesFieldDefaultValues(
|
||||
value.template.Variables || []
|
||||
(template as CustomTemplate).Variables || []
|
||||
),
|
||||
envVars: {},
|
||||
};
|
||||
|
@ -106,10 +95,9 @@ export function TemplateFieldset({
|
|||
|
||||
export function getInitialTemplateValues(): Values {
|
||||
return {
|
||||
template: undefined,
|
||||
templateId: undefined,
|
||||
type: undefined,
|
||||
variables: [],
|
||||
file: '',
|
||||
envVars: {},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,8 +7,8 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
|
|||
import { server } from '@/setup-tests/server';
|
||||
import selectEvent from '@/react/test-utils/react-select';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
|
||||
import { SelectedTemplateValue } from './types';
|
||||
import { TemplateSelector } from './TemplateSelector';
|
||||
|
||||
test('renders TemplateSelector component', async () => {
|
||||
|
@ -109,7 +109,10 @@ function renderComponent({
|
|||
customTemplates = [],
|
||||
error,
|
||||
}: {
|
||||
onChange?: (value: SelectedTemplateValue) => void;
|
||||
onChange?: (
|
||||
template: TemplateViewModel | CustomTemplate | undefined,
|
||||
type: 'app' | 'custom' | undefined
|
||||
) => void;
|
||||
appTemplates?: Array<Partial<AppTemplate>>;
|
||||
customTemplates?: Array<Partial<CustomTemplate>>;
|
||||
error?: string;
|
||||
|
@ -128,7 +131,7 @@ function renderComponent({
|
|||
|
||||
render(
|
||||
<Wrapped
|
||||
value={{ template: undefined, type: undefined }}
|
||||
value={{ templateId: undefined, type: undefined }}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
|
|
|
@ -4,6 +4,8 @@ import { GroupBase } from 'react-select';
|
|||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
|
||||
|
@ -16,10 +18,13 @@ export function TemplateSelector({
|
|||
error,
|
||||
}: {
|
||||
value: SelectedTemplateValue;
|
||||
onChange: (value: SelectedTemplateValue) => void;
|
||||
onChange: (
|
||||
template: TemplateViewModel | CustomTemplate | undefined,
|
||||
type: 'app' | 'custom' | undefined
|
||||
) => void;
|
||||
error?: string;
|
||||
}) {
|
||||
const { getTemplate, options } = useOptions();
|
||||
const { options, getTemplate } = useOptions();
|
||||
|
||||
return (
|
||||
<FormControl label="Template" inputId="template_selector" errors={error}>
|
||||
|
@ -28,26 +33,20 @@ export function TemplateSelector({
|
|||
formatGroupLabel={GroupLabel}
|
||||
placeholder="Select an Edge stack template"
|
||||
value={{
|
||||
label: value.template?.Title,
|
||||
id: value.template?.Id,
|
||||
templateId: value.templateId,
|
||||
type: value.type,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (!value) {
|
||||
onChange({
|
||||
template: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
onChange(undefined, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, type } = value;
|
||||
if (!id || type === undefined) {
|
||||
const { templateId, type } = value;
|
||||
if (!templateId || type === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplate({ id, type });
|
||||
onChange({ template, type } as SelectedTemplateValue);
|
||||
onChange(getTemplate({ type, id: templateId }), type);
|
||||
}}
|
||||
options={options}
|
||||
data-cy="edge-stacks-create-template-selector"
|
||||
|
@ -80,7 +79,8 @@ function useOptions() {
|
|||
options:
|
||||
appTemplatesQuery.data?.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
id: template.Id,
|
||||
|
||||
templateId: template.Id,
|
||||
type: 'app' as 'app' | 'custom',
|
||||
})) || [],
|
||||
},
|
||||
|
@ -90,14 +90,16 @@ function useOptions() {
|
|||
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
|
||||
? customTemplatesQuery.data.map((template) => ({
|
||||
label: `${template.Title} - ${template.Description}`,
|
||||
id: template.Id,
|
||||
|
||||
templateId: template.Id,
|
||||
type: 'custom' as 'app' | 'custom',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
label: 'No edge custom templates available',
|
||||
id: 0,
|
||||
type: 'custom' as 'app' | 'custom',
|
||||
|
||||
templateId: undefined,
|
||||
type: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
export type SelectedTemplateValue =
|
||||
| { template: CustomTemplate; type: 'custom' }
|
||||
| { template: TemplateViewModel; type: 'app' }
|
||||
| { template: undefined; type: undefined };
|
||||
| { templateId: number; type: 'custom' }
|
||||
| { templateId: number; type: 'app' }
|
||||
| { templateId: undefined; type: undefined };
|
||||
|
||||
export type Values = {
|
||||
file?: string;
|
||||
variables: VariablesFieldValue;
|
||||
envVars: Record<string, string>;
|
||||
} & SelectedTemplateValue;
|
||||
|
|
|
@ -1,27 +1,33 @@
|
|||
import { mixed, object, SchemaOf, string } from 'yup';
|
||||
import { mixed, number, object, SchemaOf } from 'yup';
|
||||
|
||||
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
|
||||
|
||||
function validation({
|
||||
import { Values } from './types';
|
||||
|
||||
export function templateFieldsetValidation({
|
||||
customVariablesDefinitions,
|
||||
envVarDefinitions,
|
||||
}: {
|
||||
customVariablesDefinitions: VariableDefinition[];
|
||||
envVarDefinitions: Array<TemplateEnv>;
|
||||
}) {
|
||||
}): SchemaOf<Values> {
|
||||
return object({
|
||||
type: string().oneOf(['custom', 'app']).required(),
|
||||
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
|
||||
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
||||
.optional()
|
||||
.when('type', {
|
||||
is: 'app',
|
||||
then: (schema: SchemaOf<unknown, never>) => schema.required(),
|
||||
}),
|
||||
file: mixed().optional(),
|
||||
template: object().optional().default(null),
|
||||
templateId: mixed()
|
||||
.optional()
|
||||
.when('type', {
|
||||
is: true,
|
||||
then: () => number().required(),
|
||||
}),
|
||||
variables: variablesFieldValidation(customVariablesDefinitions)
|
||||
.optional()
|
||||
.when('type', {
|
||||
|
@ -30,5 +36,3 @@ function validation({
|
|||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export { validation as templateFieldsetValidation };
|
||||
|
|
32
app/react/edge/edge-stacks/CreateView/WebhookSwitch.tsx
Normal file
32
app/react/edge/edge-stacks/CreateView/WebhookSwitch.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
export function WebhookSwitch({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="form-section-title"> Webhooks </div>
|
||||
<SwitchField
|
||||
label="Create an Edge stack webhook"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack."
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
data-cy="webhook-switch"
|
||||
/>
|
||||
|
||||
{value && (
|
||||
<TextTip>
|
||||
Sending environment variables to the webhook is updating the stack
|
||||
with the new values. New variables names will be added to the stack
|
||||
and existing variables will be updated.
|
||||
</TextTip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
37
app/react/edge/edge-stacks/CreateView/types.ts
Normal file
37
app/react/edge/edge-stacks/CreateView/types.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { RegistryId } from '@/react/portainer/registries/types/registry';
|
||||
import {
|
||||
GitFormModel,
|
||||
RelativePathModel,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
|
||||
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
|
||||
import { EdgeGroup } from '../../edge-groups/types';
|
||||
import { DeploymentType, StaggerConfig } from '../types';
|
||||
|
||||
import { KubeFormValues } from './KubeManifestForm';
|
||||
import { Values as TemplateFieldsetValues } from './TemplateFieldset/types';
|
||||
|
||||
export type Method = 'editor' | 'upload' | 'repository' | 'template';
|
||||
|
||||
export interface DockerFormValues {
|
||||
method: Method;
|
||||
fileContent: string;
|
||||
file?: File;
|
||||
templateValues: TemplateFieldsetValues;
|
||||
git: GitFormModel;
|
||||
relativePath: RelativePathModel;
|
||||
}
|
||||
|
||||
export interface FormValues extends KubeFormValues, DockerFormValues {
|
||||
method: Method;
|
||||
name: string;
|
||||
groupIds: Array<EdgeGroup['Id']>;
|
||||
deploymentType: DeploymentType;
|
||||
envVars: EnvVarValues;
|
||||
privateRegistryId: RegistryId;
|
||||
prePullImage: boolean;
|
||||
retryDeploy: boolean;
|
||||
enableWebhook: boolean;
|
||||
staggerConfig: StaggerConfig;
|
||||
}
|
169
app/react/edge/edge-stacks/CreateView/useCreate.tsx
Normal file
169
app/react/edge/edge-stacks/CreateView/useCreate.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
|
||||
import {
|
||||
BasePayload,
|
||||
CreateEdgeStackPayload,
|
||||
useCreateEdgeStack,
|
||||
} from '../queries/useCreateEdgeStack/useCreateEdgeStack';
|
||||
import { DeploymentType } from '../types';
|
||||
|
||||
import { FormValues, Method } from './types';
|
||||
|
||||
export function useCreate({
|
||||
webhookId,
|
||||
template,
|
||||
templateType,
|
||||
}: {
|
||||
webhookId: string;
|
||||
template: TemplateViewModel | CustomTemplate | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const mutation = useCreateEdgeStack();
|
||||
const { user } = useCurrentUser();
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return {
|
||||
isLoading: mutation.isLoading,
|
||||
onSubmit: handleSubmit,
|
||||
};
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
const method = getMethod(
|
||||
values.method,
|
||||
getIsGitTemplate(template, templateType)
|
||||
);
|
||||
trackEvent('edge-stack-creation', {
|
||||
category: 'edge',
|
||||
metadata: buildAnalyticsMetadata(
|
||||
values.method,
|
||||
values.deploymentType,
|
||||
template?.Title
|
||||
),
|
||||
});
|
||||
|
||||
mutation.mutate(getPayload(method, values), {
|
||||
onSuccess: () => {
|
||||
router.stateService.go('^');
|
||||
},
|
||||
});
|
||||
|
||||
function getPayload(
|
||||
method: 'string' | 'file' | 'git',
|
||||
values: FormValues
|
||||
): CreateEdgeStackPayload {
|
||||
switch (method) {
|
||||
case 'file':
|
||||
if (!values.file) {
|
||||
throw new Error('File is required');
|
||||
}
|
||||
|
||||
return {
|
||||
method: 'file',
|
||||
payload: {
|
||||
...getBasePayload(values),
|
||||
file: values.file,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
case 'string':
|
||||
return {
|
||||
method: 'string',
|
||||
payload: {
|
||||
...getBasePayload(values),
|
||||
fileContent: values.fileContent,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
case 'git':
|
||||
return {
|
||||
method: 'git',
|
||||
payload: {
|
||||
...getBasePayload(values),
|
||||
git: values.git,
|
||||
relativePathSettings: values.relativePath,
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getBasePayload(values: FormValues): BasePayload {
|
||||
return {
|
||||
userId: user.Id,
|
||||
deploymentType: values.deploymentType,
|
||||
edgeGroups: values.groupIds,
|
||||
name: values.name,
|
||||
envVars: values.envVars,
|
||||
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
|
||||
prePullImage: values.prePullImage,
|
||||
retryDeploy: values.retryDeploy,
|
||||
staggerConfig: values.staggerConfig,
|
||||
useManifestNamespaces: values.useManifestNamespaces,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnalyticsMetadata(
|
||||
method: Method,
|
||||
type: DeploymentType,
|
||||
templateTitle: string | undefined
|
||||
) {
|
||||
return {
|
||||
type: methodLabel(method),
|
||||
format: type === DeploymentType.Compose ? 'compose' : 'kubernetes',
|
||||
templateName: templateTitle,
|
||||
};
|
||||
|
||||
function methodLabel(method: Method) {
|
||||
switch (method) {
|
||||
case 'repository':
|
||||
return 'git';
|
||||
case 'upload':
|
||||
return 'file-upload';
|
||||
case 'template':
|
||||
return 'template';
|
||||
case 'editor':
|
||||
default:
|
||||
return 'web-editor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMethod(
|
||||
method: 'template' | 'repository' | 'editor' | 'upload',
|
||||
isGitTemplate: boolean
|
||||
): 'string' | 'file' | 'git' {
|
||||
switch (method) {
|
||||
case 'upload':
|
||||
return 'file';
|
||||
case 'repository':
|
||||
return 'git';
|
||||
case 'template':
|
||||
if (isGitTemplate) {
|
||||
return 'git';
|
||||
}
|
||||
return 'string';
|
||||
case 'editor':
|
||||
default:
|
||||
return 'string';
|
||||
}
|
||||
}
|
||||
|
||||
function getIsGitTemplate(
|
||||
template: TemplateViewModel | CustomTemplate | undefined,
|
||||
templateType: 'app' | 'custom' | undefined
|
||||
) {
|
||||
if (templateType === 'app') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!template && !!(template as CustomTemplate).GitConfig;
|
||||
}
|
94
app/react/edge/edge-stacks/CreateView/useRenderTemplate.tsx
Normal file
94
app/react/edge/edge-stacks/CreateView/useRenderTemplate.tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
|
||||
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { DeploymentType } from '../types';
|
||||
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||
|
||||
import { DockerFormValues, FormValues } from './types';
|
||||
|
||||
export function useRenderTemplate(
|
||||
templateValues: DockerFormValues['templateValues'],
|
||||
setValues: (values: SetStateAction<DockerFormValues>) => void
|
||||
) {
|
||||
const templateQuery = useCustomTemplate(templateValues.templateId);
|
||||
|
||||
const template = templateQuery.data;
|
||||
|
||||
const templateFileQuery = useCustomTemplateFile(
|
||||
templateValues.templateId,
|
||||
!!template?.GitConfig
|
||||
);
|
||||
const [renderedFile, setRenderedFile] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (templateFileQuery.data) {
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
template?.Variables || []
|
||||
);
|
||||
|
||||
if (newFile !== renderedFile) {
|
||||
setRenderedFile(newFile);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
renderedFile,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||
number | undefined
|
||||
>(templateValues.templateId);
|
||||
|
||||
useEffect(() => {
|
||||
if (template?.Id !== currentTemplateId) {
|
||||
setCurrentTemplateId(template?.Id);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...getValuesFromTemplate(template),
|
||||
}));
|
||||
}
|
||||
}, [currentTemplateId, setValues, template]);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
function getValuesFromTemplate(
|
||||
template: CustomTemplate | undefined
|
||||
): Partial<FormValues> {
|
||||
if (!template) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
deploymentType:
|
||||
template.Type === StackType.Kubernetes
|
||||
? DeploymentType.Kubernetes
|
||||
: DeploymentType.Compose,
|
||||
git: toGitFormModel(template.GitConfig),
|
||||
...(template.EdgeSettings
|
||||
? {
|
||||
prePullImage: template.EdgeSettings.PrePullImage || false,
|
||||
retryDeploy: template.EdgeSettings.RetryDeploy || false,
|
||||
privateRegistryId: template.EdgeSettings.PrivateRegistryId,
|
||||
staggerConfig:
|
||||
template.EdgeSettings.StaggerConfig || getDefaultStaggerConfig(),
|
||||
...template.EdgeSettings.RelativePathSettings,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
37
app/react/edge/edge-stacks/CreateView/useTemplateParams.tsx
Normal file
37
app/react/edge/edge-stacks/CreateView/useTemplateParams.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
|
||||
export function useTemplateParams() {
|
||||
const [id, setTemplateId] = useParamState('templateId', (param) => {
|
||||
if (!param) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const templateId = parseInt(param, 10);
|
||||
if (Number.isNaN(templateId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return templateId;
|
||||
});
|
||||
|
||||
const [type, setTemplateType] = useParamState('templateType', (param) => {
|
||||
if (param === 'app' || param === 'custom') {
|
||||
return param;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return [{ id, type }, handleChange] as const;
|
||||
|
||||
function handleChange({
|
||||
id,
|
||||
type,
|
||||
}: {
|
||||
id: number | undefined;
|
||||
type: 'app' | 'custom' | undefined;
|
||||
}) {
|
||||
setTemplateId(id);
|
||||
setTemplateType(type);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue