1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +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

This commit is contained in:
Chaim Lev-Ari 2024-05-06 08:08:03 +03:00 committed by GitHub
parent f22aed34b5
commit 8a81d95253
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1878 additions and 1005 deletions

View 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,
};
}

View file

@ -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]
);
}

View 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 />
</>
);
}

View 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>
</>
);
}

View 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,
}));
}
}

View 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>
);
}

View 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>
</>
);
}

View file

@ -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]
);
}

View file

@ -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,

View file

@ -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} />

View file

@ -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} />

View file

@ -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: {},
};
}

View file

@ -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}
/>

View file

@ -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,
},
],
},

View file

@ -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;

View file

@ -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 };

View 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>
);
}

View 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;
}

View 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;
}

View 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,
}
: {}),
};
}

View 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);
}
}

View file

@ -252,7 +252,13 @@ function InnerForm({
errors={errors.authentication}
/>
{isBE && <RelativePathFieldset value={values.relativePath} isEditing />}
{isBE && (
<RelativePathFieldset
values={values.relativePath}
isEditing
onChange={() => {}}
/>
)}
<EnvironmentVariablesPanel
onChange={(value) => setFieldValue('envVars', value)}

View file

@ -1,12 +1,13 @@
import _ from 'lodash';
import { useState } from 'react';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { Select } from '@@/form-components/ReactSelect';
import { FormSection } from '@@/form-components/FormSection';
import { FormError } from '@@/form-components/FormError';
import { Link } from '@@/Link';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
@ -29,20 +30,28 @@ export function EdgeGroupsSelector({
isGroupVisible = () => true,
required,
}: Props) {
const [inputId] = useState(() => _.uniqueId('edge-groups-selector-'));
const selector = (
<InnerSelector
value={value}
onChange={onChange}
isGroupVisible={isGroupVisible}
inputId={inputId}
/>
);
return horizontal ? (
<FormControl errors={error} label="Edge Groups" required={required}>
<FormControl
errors={error}
label="Edge Groups"
required={required}
inputId={inputId}
>
{selector}
</FormControl>
) : (
<FormSection title={`Edge Groups${required ? ' *' : ''}`}>
<FormSection title={`Edge Groups${required ? ' *' : ''}`} htmlFor={inputId}>
<div className="form-group">
<div className="col-sm-12">{selector} </div>
{error && (
@ -59,10 +68,12 @@ function InnerSelector({
value,
onChange,
isGroupVisible,
inputId,
}: {
isGroupVisible(group: EdgeGroup): boolean;
value: SingleValue[];
onChange: (value: SingleValue[]) => void;
inputId: string;
}) {
const edgeGroupsQuery = useEdgeGroups();
@ -86,6 +97,7 @@ function InnerSelector({
placeholder="Select one or multiple group(s)"
closeMenuOnSelect={false}
data-cy="edge-stacks-groups-selector"
inputId={inputId}
/>
) : (
<div className="small text-muted">

View file

@ -1,6 +1,4 @@
import _ from 'lodash';
import { EditorType } from '@/react/edge/edge-stacks/types';
import { DeploymentType } from '@/react/edge/edge-stacks/types';
import { BoxSelector } from '@@/BoxSelector';
import { BoxSelectorOption } from '@@/BoxSelector/types';
@ -10,8 +8,8 @@ import {
} from '@@/BoxSelector/common-options/deployment-methods';
interface Props {
value: number;
onChange(value: number): void;
value: DeploymentType;
onChange(value: DeploymentType): void;
hasDockerEndpoint: boolean;
hasKubeEndpoint: boolean;
allowKubeToSelectCompose?: boolean;
@ -24,10 +22,10 @@ export function EdgeStackDeploymentTypeSelector({
hasKubeEndpoint,
allowKubeToSelectCompose,
}: Props) {
const deploymentOptions: BoxSelectorOption<number>[] = _.compact([
const deploymentOptions: BoxSelectorOption<DeploymentType>[] = [
{
...compose,
value: EditorType.Compose,
value: DeploymentType.Compose,
disabled: () => !allowKubeToSelectCompose && hasKubeEndpoint,
tooltip: () =>
hasKubeEndpoint
@ -36,7 +34,7 @@ export function EdgeStackDeploymentTypeSelector({
},
{
...kubernetes,
value: EditorType.Kubernetes,
value: DeploymentType.Kubernetes,
disabled: () => hasDockerEndpoint,
tooltip: () =>
hasDockerEndpoint
@ -44,7 +42,7 @@ export function EdgeStackDeploymentTypeSelector({
: '',
iconType: 'logo',
},
]);
];
return (
<>

View file

@ -0,0 +1,309 @@
import { number, string, object, SchemaOf } from 'yup';
import { FormikErrors } from 'formik';
import { useState, useEffect } from 'react';
import { FormSection } from '@@/form-components/FormSection';
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { FormControl } from '@@/form-components/FormControl';
import { Button, ButtonGroup } from '@@/buttons';
import { StaggerParallelFieldset } from './StaggerParallelFieldset';
import {
StaggerConfig,
StaggerOption,
StaggerParallelOption,
UpdateFailureAction,
} from './StaggerFieldset.types';
interface Props {
values: StaggerConfig;
onChange: (value: Partial<StaggerConfig>) => void;
errors?: FormikErrors<StaggerConfig>;
isEdit?: boolean;
}
const staggerOptions = [
{
value: StaggerOption.AllAtOnce,
label: 'All edge devices at once',
},
{
value: StaggerOption.Parallel,
label: 'Parallel edge device(s)',
},
] as const;
export function StaggerFieldset({
values: initialValue,
onChange,
errors,
isEdit = true,
}: Props) {
const [values, setControlledValues] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
useEffect(() => {
if (!!initialValue && initialValue.StaggerOption !== values.StaggerOption) {
setControlledValues(initialValue);
}
}, [initialValue, values]);
return (
<FormSection title="Update configurations">
{!isEdit && (
<div className="form-group">
<div className="col-sm-12">
<TextTip color="blue">
Please note that the &apos;Update Configuration&apos; setting
takes effect exclusively during edge stack updates, whether
triggered manually, through webhook events, or via GitOps updates
processes
</TextTip>
</div>
</div>
)}
<div className="form-group">
<div className="col-sm-12">
<RadioGroup
options={staggerOptions}
selectedOption={values.StaggerOption}
onOptionChange={(value) => {
handleChange({ StaggerOption: value });
}}
name="StaggerOption"
/>
</div>
</div>
{values.StaggerOption === StaggerOption.Parallel && (
<div className="mb-2">
<TextTip color="blue">
Specify the number of device(s) to be updated concurrently.
{values.StaggerParallelOption ===
StaggerParallelOption.Incremental && (
<div className="mb-2">
For example, if you start with 2 devices and multiply by 5, the
update will initially cover 2 edge devices, then 10 devices (2 x
5), followed by 50 devices (10 x 5), and so on.
</div>
)}
</TextTip>
<StaggerParallelFieldset
values={values}
onChange={handleChange}
errors={errors}
/>
<FormControl
label="Timeout"
inputId="timeout"
errors={errors?.Timeout}
>
<div>
<div style={{ display: 'inline-block', width: '150px' }}>
<Input
name="Timeout"
id="stagger-timeout"
placeholder="eg. 5 (optional)"
value={values.Timeout}
onChange={(e) =>
handleChange({
Timeout: e.currentTarget.value,
})
}
data-cy="edge-stacks-stagger-timeout-input"
/>
</div>
<span> {' minute(s) '} </span>
</div>
</FormControl>
<FormControl
label="Update delay"
inputId="update-delay"
errors={errors?.UpdateDelay}
>
<div>
<div style={{ display: 'inline-block', width: '150px' }}>
<Input
name="UpdateDelay"
data-cy="edge-stacks-stagger-update-delay-input"
id="stagger-update-delay"
placeholder="eg. 5 (optional)"
value={values.UpdateDelay}
onChange={(e) =>
handleChange({
UpdateDelay: e.currentTarget.value,
})
}
/>
</div>
<span> {' minute(s) '} </span>
</div>
</FormControl>
<FormControl
label="Update failure action"
inputId="update-failure-action"
errors={errors?.UpdateFailureAction}
>
<ButtonGroup>
<Button
className="btn-box-shadow"
data-cy="edge-stacks-stagger-update-failure-action-continue-button"
color={
values.UpdateFailureAction === UpdateFailureAction.Continue
? 'primary'
: 'light'
}
onClick={() =>
handleChange({
UpdateFailureAction: UpdateFailureAction.Continue,
})
}
>
Continue
</Button>
<Button
className="btn-box-shadow"
data-cy="edge-stacks-stagger-update-failure-action-pause-button"
color={
values.UpdateFailureAction === UpdateFailureAction.Pause
? 'primary'
: 'light'
}
onClick={() =>
handleChange({
UpdateFailureAction: UpdateFailureAction.Pause,
})
}
>
Pause
</Button>
<Button
className="btn-box-shadow"
data-cy="edge-stacks-stagger-update-failure-action-rollback-button"
color={
values.UpdateFailureAction === UpdateFailureAction.Rollback
? 'primary'
: 'light'
}
onClick={() =>
handleChange({
UpdateFailureAction: UpdateFailureAction.Rollback,
})
}
>
Rollback
</Button>
</ButtonGroup>
</FormControl>
</div>
)}
</FormSection>
);
function handleChange(partialValue: Partial<StaggerConfig>) {
onChange(partialValue);
setControlledValues((values) => ({ ...values, ...partialValue }));
}
}
export function staggerConfigValidation(): SchemaOf<StaggerConfig> {
return object({
StaggerOption: number()
.oneOf([StaggerOption.AllAtOnce, StaggerOption.Parallel])
.required('Stagger option is required'),
StaggerParallelOption: number()
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.oneOf([
StaggerParallelOption.Fixed,
StaggerParallelOption.Incremental,
]),
})
.optional(),
DeviceNumber: number()
.default(0)
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.when('StaggerParallelOption', {
is: StaggerParallelOption.Fixed,
then: (schema) =>
schema
.required('Devices number is at least 1')
.min(1, 'Devices number is at least 1'),
}),
})
.optional(),
DeviceNumberStartFrom: number()
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.when('StaggerParallelOption', {
is: StaggerParallelOption.Incremental,
then: (schema) =>
schema
.min(1, 'Devices number start from at least 1')
.required('Devices number is required'),
}),
})
.optional(),
DeviceNumberIncrementBy: number()
.default(2)
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.when('StaggerParallelOption', {
is: StaggerParallelOption.Incremental,
then: (schema) =>
schema
.min(2)
.max(10)
.required('Devices number increment by is required'),
}),
})
.optional(),
Timeout: string()
.default('')
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.test(
'is-number',
'Timeout must be a number',
(value) => !Number.isNaN(Number(value))
),
})
.optional(),
UpdateDelay: string()
.default('')
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.test(
'is-number',
'Timeout must be a number',
(value) => !Number.isNaN(Number(value))
),
})
.optional(),
UpdateFailureAction: number()
.default(UpdateFailureAction.Continue)
.when('StaggerOption', {
is: StaggerOption.Parallel,
then: (schema) =>
schema.oneOf([
UpdateFailureAction.Continue,
UpdateFailureAction.Pause,
UpdateFailureAction.Rollback,
]),
})
.optional(),
});
}

View file

@ -0,0 +1,130 @@
import { FormikErrors } from 'formik';
import { Select, Input } from '@@/form-components/Input';
import { FormError } from '@@/form-components/FormError';
import { StaggerConfig, StaggerParallelOption } from './StaggerFieldset.types';
interface Props {
values: StaggerConfig;
onChange: (value: Partial<StaggerConfig>) => void;
errors?: FormikErrors<StaggerConfig>;
}
export function StaggerParallelFieldset({ values, onChange, errors }: Props) {
const staggerParallelOptions = [
{
value: StaggerParallelOption.Fixed.toString(),
label: 'Number of device(s)',
},
{
value: StaggerParallelOption.Incremental.toString(),
label: 'Exponential rollout',
},
];
const deviceNumberIncrementBy = [
{
value: '2',
label: '2',
},
{
value: '5',
label: '5',
},
{
value: '10',
label: '10',
},
];
return (
<div
className='form-group mb-5 mt-2 after:clear-both after:table after:content-[""]' // to fix issues with float"
>
<div className="col-sm-3 col-lg-2">
<Select
id="stagger-parallel-option"
data-cy="edge-stack-stagger-parallel-option-select"
value={values.StaggerParallelOption?.toString()}
onChange={(e) =>
handleChange({
StaggerParallelOption: parseInt(e.currentTarget.value, 10),
})
}
options={staggerParallelOptions}
/>
</div>
{values.StaggerParallelOption === StaggerParallelOption.Fixed && (
<div className="col-sm-9 col-lg-10">
<Input
name="DeviceNumber"
data-cy="edge-stack-device-number-input"
id="device-number"
type="number"
placeholder="eg. 1 or 10"
min={1}
value={values.DeviceNumber || ''}
onChange={(e) => {
handleChange({
DeviceNumber: e.currentTarget.valueAsNumber || undefined,
});
}}
/>
{errors?.DeviceNumber && (
<FormError>{errors?.DeviceNumber}</FormError>
)}
</div>
)}
{values.StaggerParallelOption === StaggerParallelOption.Incremental && (
<div className="col-sm-9 col-lg-10">
<div>
<span> {' start with '} </span>
<div style={{ display: 'inline-block', width: '150px' }}>
<Input
name="DeviceNumberStartFrom"
data-cy="edge-stack-device-number-start-from-input"
type="number"
id="device-number-start-from"
min={1}
placeholder="eg. 1"
value={values.DeviceNumberStartFrom}
onChange={(e) =>
handleChange({
DeviceNumberStartFrom:
e.currentTarget.value !== ''
? e.currentTarget.valueAsNumber
: 0,
})
}
/>
</div>
<span> {' device(s) and multiply the group size by '} </span>
<Select
id="device-number-incremental"
data-cy="edge-stack-device-number-incremental-select"
value={values.DeviceNumberIncrementBy}
style={{ display: 'inline-block', width: '150px' }}
onChange={(e) =>
handleChange({
DeviceNumberIncrementBy: parseInt(e.currentTarget.value, 10),
})
}
options={deviceNumberIncrementBy}
/>
<span>{' for each rollout '} </span>
</div>
{errors?.DeviceNumberStartFrom && (
<FormError>{errors?.DeviceNumberStartFrom}</FormError>
)}
</div>
)}
</div>
);
function handleChange(partialValue: Partial<StaggerConfig>) {
onChange(partialValue);
}
}

View file

@ -7,6 +7,8 @@ import {
GitFormModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
import { UserId } from '@/portainer/users/types';
import { DeploymentType, StaggerConfig } from '../../types';
@ -18,7 +20,8 @@ export function useCreateEdgeStack() {
return useMutation(createEdgeStack);
}
type BasePayload = {
export type BasePayload = {
userId: UserId;
/** Name of the stack */
name: string;
/** Content of the Stack file */
@ -87,34 +90,7 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
Webhook: payload.webhook,
});
case 'git':
return createStackFromGit({
deploymentType: payload.deploymentType,
edgeGroups: payload.edgeGroups,
name: payload.name,
envVars: payload.envVars,
prePullImage: payload.prePullImage,
registries: payload.registries,
retryDeploy: payload.retryDeploy,
staggerConfig: payload.staggerConfig,
useManifestNamespaces: payload.useManifestNamespaces,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
filePathInRepository: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType:
payload.relativePathSettings?.PerDeviceConfigsGroupMatchType,
perDeviceConfigsMatchType:
payload.relativePathSettings?.PerDeviceConfigsMatchType,
perDeviceConfigsPath:
payload.relativePathSettings?.PerDeviceConfigsPath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.git.AutoUpdate,
});
return createStackAndGitCredential(payload.userId, payload);
case 'string':
return createStackFromFileContent({
deploymentType: payload.deploymentType,
@ -133,3 +109,41 @@ function createEdgeStack({ method, payload }: CreateEdgeStackPayload) {
throw new Error('Invalid method');
}
}
async function createStackAndGitCredential(
userId: UserId,
payload: BasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
}
) {
const newGitModel = await saveGitCredentialsIfNeeded(userId, payload.git);
return createStackFromGit({
deploymentType: payload.deploymentType,
edgeGroups: payload.edgeGroups,
name: payload.name,
envVars: payload.envVars,
prePullImage: payload.prePullImage,
registries: payload.registries,
retryDeploy: payload.retryDeploy,
staggerConfig: payload.staggerConfig,
useManifestNamespaces: payload.useManifestNamespaces,
repositoryUrl: newGitModel.RepositoryURL,
repositoryReferenceName: newGitModel.RepositoryReferenceName,
filePathInRepository: newGitModel.ComposeFilePathInRepository,
repositoryAuthentication: newGitModel.RepositoryAuthentication,
repositoryUsername: newGitModel.RepositoryUsername,
repositoryPassword: newGitModel.RepositoryPassword,
repositoryGitCredentialId: newGitModel.RepositoryGitCredentialID,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType:
payload.relativePathSettings?.PerDeviceConfigsGroupMatchType,
perDeviceConfigsMatchType:
payload.relativePathSettings?.PerDeviceConfigsMatchType,
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
tlsSkipVerify: newGitModel.TLSSkipVerify,
autoUpdate: newGitModel.AutoUpdate,
});
}

View file

@ -101,7 +101,4 @@ export type EdgeStack = RelativePathModel & {
FilesystemPath?: string;
};
export enum EditorType {
Compose,
Kubernetes,
}
export { DeploymentType as EditorType };