From e43d076269595aadbd9a5bc38cbab3cc49512dab Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 15 Nov 2023 14:43:18 +0200 Subject: [PATCH] feat(edge/templates): introduce edge specific settings [EE-6276] (#10609) --- app/edge/__module.js | 2 +- .../create-edge-stack-view.controller.js | 19 ++- app/react-tools/apply-set-state-action.ts | 12 ++ app/react/components/CodeEditor.module.css | 6 + .../components/RadioGroup/RadioGroup.tsx | 36 +++++ app/react/edge/edge-groups/types.ts | 6 +- .../EditEdgeStackForm/EditEdgeStackForm.tsx | 53 +++---- .../PrivateRegistryFieldsetWrapper.tsx | 50 +++--- .../edge-stacks/components/PrePullToggle.tsx | 24 +++ .../components/RetryDeployToggle.tsx | 24 +++ .../components/StaggerFieldset.types.ts | 39 +++++ .../useCreateEdgeStack/createStackFromFile.ts | 47 ++++++ .../createStackFromFileContent.ts | 47 ++++++ .../useCreateEdgeStack/createStackFromGit.ts | 74 +++++++++ .../useCreateEdgeStack/useCreateEdgeStack.ts | 135 +++++++++++++++++ .../useCreateEdgeStackFromFileContent.ts | 49 ------ .../queries/useCreateEdgeStackFromGit.ts | 142 ------------------ .../edge-stacks/queries/useParseRegistries.ts | 44 ++++++ app/react/edge/edge-stacks/types.ts | 14 +- .../templates/AppTemplatesView/DeployForm.tsx | 28 ++-- .../CreateView/CreateTemplateForm.tsx | 2 + .../CreateView/EdgeSettingsFieldset.tsx | 81 ++++++++++ .../EdgeSettingsFieldset.validation.ts | 19 +++ .../custom-templates/CreateView/InnerForm.tsx | 41 ++++- .../custom-templates/CreateView/types.ts | 2 + .../CreateView/useValidation.tsx | 2 + .../EditView/EditTemplateForm.tsx | 2 + .../custom-templates/EditView/EditView.tsx | 4 +- .../custom-templates/EditView/InnerForm.tsx | 42 +++++- .../custom-templates/EditView/types.ts | 2 + .../EditView/useValidation.tsx | 3 + .../gitops/AuthFieldset/AuthFieldset.tsx | 2 +- app/react/portainer/gitops/GitForm.tsx | 2 +- .../RelativePathFieldset.tsx | 14 +- .../gitops/RelativePathFieldset/types.ts | 37 +++++ .../gitops/RelativePathFieldset/validation.ts | 12 +- app/react/portainer/gitops/types.ts | 16 +- app/react/portainer/settings/types.ts | 2 +- .../ListView/CustomTemplatesListItem.tsx | 2 +- .../queries/useCreateTemplateMutation.ts | 26 +++- .../queries/useUpdateTemplateMutation.ts | 3 +- .../templates/custom-templates/types.ts | 37 ++++- 42 files changed, 885 insertions(+), 319 deletions(-) create mode 100644 app/react-tools/apply-set-state-action.ts create mode 100644 app/react/components/RadioGroup/RadioGroup.tsx create mode 100644 app/react/edge/edge-stacks/components/PrePullToggle.tsx create mode 100644 app/react/edge/edge-stacks/components/RetryDeployToggle.tsx create mode 100644 app/react/edge/edge-stacks/components/StaggerFieldset.types.ts create mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFile.ts create mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFileContent.ts create mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts create mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts delete mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts delete mode 100644 app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts create mode 100644 app/react/edge/edge-stacks/queries/useParseRegistries.ts create mode 100644 app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx create mode 100644 app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts create mode 100644 app/react/portainer/gitops/RelativePathFieldset/types.ts diff --git a/app/edge/__module.js b/app/edge/__module.js index 27b182403..e0b31efdf 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -181,7 +181,7 @@ angular $stateRegistryProvider.register({ name: 'edge.templates.custom.edit', - url: '/:templateId', + url: '/:id', views: { 'content@': { diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js index 2116bf346..dfae4726b 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js @@ -1,4 +1,4 @@ -import { EditorType } from '@/react/edge/edge-stacks/types'; +import { DeploymentType, EditorType } from '@/react/edge/edge-stacks/types'; import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils'; import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; @@ -8,6 +8,8 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { getCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate'; import { notifyError } from '@/portainer/services/notifications'; import { getCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; +import { toGitFormModel } from '@/react/portainer/gitops/types'; +import { StackType } from '@/react/common/stacks/types'; export default class CreateEdgeStackViewController { /* @ngInject */ @@ -71,6 +73,21 @@ export default class CreateEdgeStackViewController { onChangeTemplate(template) { return this.$scope.$evalAsync(() => { this.state.selectedTemplate = template; + + this.formValues = { + ...this.formValues, + DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose, + ...toGitFormModel(template.GitConfig), + ...(template.EdgeSettings + ? { + PrePullImage: template.EdgeSettings.PrePullImage || false, + RetryDeploy: template.EdgeSettings.RetryDeploy || false, + Registries: template.EdgeSettings.PrivateRegistryId ? [template.EdgeSettings.PrivateRegistryId] : [], + SupportRelativePath: template.EdgeSettings.RelativePathSettings.SupportRelativePath || false, + FilesystemPath: template.EdgeSettings.RelativePathSettings.FilesystemPath || '', + } + : {}), + }; }); } diff --git a/app/react-tools/apply-set-state-action.ts b/app/react-tools/apply-set-state-action.ts new file mode 100644 index 000000000..bf6f0d17f --- /dev/null +++ b/app/react-tools/apply-set-state-action.ts @@ -0,0 +1,12 @@ +import { SetStateAction } from 'react'; + +export function applySetStateAction(applier: SetStateAction, values?: T) { + if (isFunction(applier)) { + return values ? applier(values) : undefined; + } + return applier; + + function isFunction(value: unknown): value is (prevState: T) => T { + return typeof value === 'function'; + } +} diff --git a/app/react/components/CodeEditor.module.css b/app/react/components/CodeEditor.module.css index f9cc51ed5..0fb832a14 100644 --- a/app/react/components/CodeEditor.module.css +++ b/app/react/components/CodeEditor.module.css @@ -117,3 +117,9 @@ .root :global(.cm-content[contenteditable='true']) { min-height: 100%; } + +.root :global(.cm-content[aria-readonly='true']) { + @apply bg-gray-3; + @apply th-dark:bg-gray-iron-10; + @apply th-highcontrast:bg-black; +} diff --git a/app/react/components/RadioGroup/RadioGroup.tsx b/app/react/components/RadioGroup/RadioGroup.tsx new file mode 100644 index 000000000..83da291fc --- /dev/null +++ b/app/react/components/RadioGroup/RadioGroup.tsx @@ -0,0 +1,36 @@ +import { Option } from '@@/form-components/PortainerSelect'; + +interface Props { + options: Array> | ReadonlyArray>; + selectedOption: T; + name: string; + onOptionChange: (value: T) => void; +} + +export function RadioGroup({ + options, + selectedOption, + name, + onOptionChange, +}: Props) { + return ( +
+ {options.map((option) => ( + + onOptionChange(option.value)} + style={{ margin: '0 4px 0 0' }} + /> + {option.label} + + ))} +
+ ); +} diff --git a/app/react/edge/edge-groups/types.ts b/app/react/edge/edge-groups/types.ts index c60778ea7..2820e63a5 100644 --- a/app/react/edge/edge-groups/types.ts +++ b/app/react/edge/edge-groups/types.ts @@ -1,4 +1,7 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + EnvironmentId, + EnvironmentType, +} from '@/react/portainer/environments/types'; import { TagId } from '@/portainer/tags/types'; export interface EdgeGroup { @@ -8,4 +11,5 @@ export interface EdgeGroup { TagIds: TagId[]; Endpoints: EnvironmentId[]; PartialMatch: boolean; + EndpointTypes: EnvironmentType[]; } diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx index 0d8e80227..c4787429d 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx @@ -20,6 +20,9 @@ import { envVarValidation, } from '@@/form-components/EnvironmentVariablesFieldset'; +import { PrePullToggle } from '../../components/PrePullToggle'; +import { RetryDeployToggle } from '../../components/RetryDeployToggle'; + import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper'; import { FormValues } from './types'; import { ComposeForm } from './ComposeForm'; @@ -175,46 +178,30 @@ function InnerForm({ setFieldValue('privateRegistryId', value)} - isValid={isValid} - values={values} - stackName={edgeStack.Name} + values={{ + fileContent: values.content, + }} onFieldError={(error) => setFieldError('privateRegistryId', error)} error={errors.privateRegistryId} /> - setFieldValue('envVars', value)} - values={values.envVars} - errors={errors.envVars} - /> - {values.deploymentType === DeploymentType.Compose && ( <> -
-
- setFieldValue('prePullImage', value)} - /> -
-
+ setFieldValue('envVars', value)} + values={values.envVars} + errors={errors.envVars} + /> -
-
- setFieldValue('retryDeploy', value)} - /> -
-
+ setFieldValue('prePullImage', value)} + value={values.prePullImage} + /> + + setFieldValue('retryDeploy', value)} + value={values.retryDeploy} + /> )} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx index e04bb6869..6066bc538 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx @@ -2,29 +2,32 @@ import _ from 'lodash'; import { notifyError } from '@/portainer/services/notifications'; import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset'; -import { useCreateEdgeStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent'; import { useRegistries } from '@/react/portainer/registries/queries/useRegistries'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +import { useParseRegistries } from '../../queries/useParseRegistries'; import { FormValues } from './types'; export function PrivateRegistryFieldsetWrapper({ value, - isValid, error, onChange, - values, - stackName, onFieldError, + values, + isGit, }: { value: FormValues['privateRegistryId']; - isValid: boolean; error?: string; onChange: (value?: number) => void; - values: FormValues; - stackName: string; + values: { + fileContent?: string; + file?: File; + }; onFieldError: (message: string) => void; + isGit?: boolean; }) { - const dryRunMutation = useCreateEdgeStackFromFileContent(); + const dryRunMutation = useParseRegistries(); const registriesQuery = useRegistries(); @@ -35,34 +38,37 @@ export function PrivateRegistryFieldsetWrapper({ return ( matchRegistry()} + onChange={() => matchRegistry(values)} onSelect={(value) => onChange(value)} isActive={!!value} clearRegistries={() => onChange(undefined)} + method={isGit ? 'repository' : 'file'} /> ); - async function matchRegistry() { - try { - const response = await dryRunMutation.mutateAsync({ - name: `${stackName}-dryrun`, - stackFileContent: values.content, - edgeGroups: values.edgeGroups, - deploymentType: values.deploymentType, - dryRun: true, - }); + async function matchRegistry(values: { fileContent?: string; file?: File }) { + if (isGit) { + return; + } - if (response.Registries.length === 0) { + try { + if (!isBE) { + return; + } + + const registries = await dryRunMutation.mutateAsync(values); + + if (registries.length === 0) { onChange(undefined); return; } - const validRegistry = onlyOne(response.Registries); + const validRegistry = onlyOne(registries); if (validRegistry) { - onChange(response.Registries[0]); + onChange(registries[0]); } else { onChange(undefined); onFieldError( diff --git a/app/react/edge/edge-stacks/components/PrePullToggle.tsx b/app/react/edge/edge-stacks/components/PrePullToggle.tsx new file mode 100644 index 000000000..5809aaafb --- /dev/null +++ b/app/react/edge/edge-stacks/components/PrePullToggle.tsx @@ -0,0 +1,24 @@ +import { SwitchField } from '@@/form-components/SwitchField'; + +export function PrePullToggle({ + value, + onChange, +}: { + value: boolean; + onChange: (value: boolean) => void; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/app/react/edge/edge-stacks/components/RetryDeployToggle.tsx b/app/react/edge/edge-stacks/components/RetryDeployToggle.tsx new file mode 100644 index 000000000..aff254ec7 --- /dev/null +++ b/app/react/edge/edge-stacks/components/RetryDeployToggle.tsx @@ -0,0 +1,24 @@ +import { SwitchField } from '@@/form-components/SwitchField'; + +export function RetryDeployToggle({ + value, + onChange, +}: { + value: boolean; + onChange: (value: boolean) => void; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/app/react/edge/edge-stacks/components/StaggerFieldset.types.ts b/app/react/edge/edge-stacks/components/StaggerFieldset.types.ts new file mode 100644 index 000000000..e8c7d09f7 --- /dev/null +++ b/app/react/edge/edge-stacks/components/StaggerFieldset.types.ts @@ -0,0 +1,39 @@ +export type StaggerConfig = { + StaggerOption: StaggerOption; + StaggerParallelOption?: StaggerParallelOption; + DeviceNumber?: number; + DeviceNumberStartFrom?: number; + DeviceNumberIncrementBy?: number; + Timeout?: string; + UpdateDelay?: string; + UpdateFailureAction?: UpdateFailureAction; +}; + +export enum StaggerOption { + AllAtOnce = 1, + Parallel, +} + +export enum StaggerParallelOption { + Fixed = 1, + Incremental, +} + +export enum UpdateFailureAction { + Continue = 1, + Pause, + Rollback, +} + +export function getDefaultStaggerConfig(): StaggerConfig { + return { + StaggerOption: StaggerOption.AllAtOnce, + StaggerParallelOption: StaggerParallelOption.Fixed, + DeviceNumber: 1, + DeviceNumberStartFrom: 0, + DeviceNumberIncrementBy: 2, + Timeout: '', + UpdateDelay: '', + UpdateFailureAction: UpdateFailureAction.Continue, + }; +} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFile.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFile.ts new file mode 100644 index 000000000..089f8fe89 --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFile.ts @@ -0,0 +1,47 @@ +import axios, { + json2formData, + parseAxiosError, +} from '@/portainer/services/axios'; +import { RegistryId } from '@/react/portainer/registries/types'; +import { Pair } from '@/react/portainer/settings/types'; +import { EdgeGroup } from '@/react/edge/edge-groups/types'; + +import { DeploymentType, EdgeStack, StaggerConfig } from '../../types'; +import { buildUrl } from '../buildUrl'; + +/** + * Payload to create an EdgeStack from a git repository + */ +export type FileUploadPayload = { + Name: string; + file: File; + EdgeGroups: Array; + DeploymentType: DeploymentType; + Registries?: Array; + /** * Uses the manifest's namespaces instead of the default one */ + UseManifestNamespaces?: boolean; + PrePullImage?: boolean; + RetryDeploy?: boolean; + /** List of environment variables */ + EnvVars?: Array; + /** Configuration for stagger updates */ + StaggerConfig?: StaggerConfig; + Webhook?: string; +}; + +export async function createStackFromFile(payload: FileUploadPayload) { + try { + const { data } = await axios.post( + buildUrl(undefined, 'create/file'), + json2formData(payload), + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFileContent.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFileContent.ts new file mode 100644 index 000000000..299968bad --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromFileContent.ts @@ -0,0 +1,47 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { RegistryId } from '@/react/portainer/registries/types'; +import { Pair } from '@/react/portainer/settings/types'; +import { EdgeGroup } from '@/react/edge/edge-groups/types'; + +import { DeploymentType, EdgeStack, StaggerConfig } from '../../types'; +import { buildUrl } from '../buildUrl'; + +/** + * Payload for creating an EdgeStack from a string + */ +export interface FileContentPayload { + /** Name of the stack */ + name: string; + /** Content of the Stack file */ + stackFileContent: string; + /** List of identifiers of EdgeGroups */ + edgeGroups: Array; + /** Deployment type to deploy this stack */ + deploymentType: DeploymentType; + /** List of Registries to use for this stack */ + registries?: Array; + /** Uses the manifest's namespaces instead of the default one */ + useManifestNamespaces?: boolean; + /** Pre Pull image */ + prePullImage?: boolean; + /** Retry deploy */ + retryDeploy?: boolean; + /** Optional webhook configuration */ + webhook?: string; + /** List of environment variables */ + envVars?: Array; + /** Configuration for stagger updates */ + staggerConfig?: StaggerConfig; +} + +export async function createStackFromFileContent(payload: FileContentPayload) { + try { + const { data } = await axios.post( + buildUrl(undefined, 'create/string'), + payload + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts new file mode 100644 index 000000000..e7a9cb6a9 --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/createStackFromGit.ts @@ -0,0 +1,74 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { RegistryId } from '@/react/portainer/registries/types'; +import { Pair } from '@/react/portainer/settings/types'; +import { EdgeGroup } from '@/react/edge/edge-groups/types'; +import { AutoUpdateModel } from '@/react/portainer/gitops/types'; + +import { DeploymentType, EdgeStack, StaggerConfig } from '../../types'; +import { buildUrl } from '../buildUrl'; + +/** + * Payload to create an EdgeStack from a git repository + */ +export type GitRepositoryPayload = { + /** Name of the stack */ + name: string; + /** URL of a Git repository hosting the Stack file */ + repositoryUrl: string; + /** Reference name of a Git repository hosting the Stack file */ + repositoryReferenceName?: string; + /** Use basic authentication to clone the Git repository */ + repositoryAuthentication?: boolean; + /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ + repositoryUsername?: string; + /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ + repositoryPassword?: string; + /** GitCredentialID used to identify the binded git credential */ + repositoryGitCredentialId?: number; + /** Path to the Stack file inside the Git repository */ + filePathInRepository?: string; + /** List of identifiers of EdgeGroups */ + edgeGroups: Array; + /** Deployment type to deploy this stack. Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'. Compose is enabled only for docker environments, kubernetes is enabled only for kubernetes environments, nomad is enabled only for nomad environments */ + deploymentType: DeploymentType; + /** List of Registries to use for this stack */ + registries?: Array; + /** Uses the manifest's namespaces instead of the default one */ + useManifestNamespaces?: boolean; + /** Pre Pull image */ + prePullImage?: boolean; + /** Retry deploy */ + retryDeploy?: boolean; + /** TLSSkipVerify skips SSL verification when cloning the Git repository */ + tlsSkipVerify?: boolean; + /** Optional GitOps update configuration */ + autoUpdate?: AutoUpdateModel; + /** Whether the stack supports relative path volume */ + supportRelativePath?: boolean; + /** Local filesystem path */ + filesystemPath?: string; + /** Whether the edge stack supports per device configs */ + supportPerDeviceConfigs?: boolean; + /** Per device configs match type */ + perDeviceConfigsMatchType?: string; + /** Per device configs group match type */ + perDeviceConfigsGroupMatchType?: string; + /** Per device configs path */ + perDeviceConfigsPath?: string; + /** List of environment variables */ + envVars?: Array; + /** Configuration for stagger updates */ + staggerConfig?: StaggerConfig; +}; + +export async function createStackFromGit(payload: GitRepositoryPayload) { + try { + const { data } = await axios.post( + buildUrl(undefined, 'create/repository'), + payload + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts new file mode 100644 index 000000000..80cc1350e --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack.ts @@ -0,0 +1,135 @@ +import { useMutation } from 'react-query'; + +import { EdgeGroup } from '@/react/edge/edge-groups/types'; +import { RegistryId } from '@/react/portainer/registries/types'; +import { Pair } from '@/react/portainer/settings/types'; +import { + GitFormModel, + RelativePathModel, +} from '@/react/portainer/gitops/types'; + +import { DeploymentType, StaggerConfig } from '../../types'; + +import { createStackFromFile } from './createStackFromFile'; +import { createStackFromFileContent } from './createStackFromFileContent'; +import { createStackFromGit } from './createStackFromGit'; + +export function useCreateEdgeStack() { + return useMutation(createEdgeStack); +} + +type BasePayload = { + /** Name of the stack */ + name: string; + /** Content of the Stack file */ + /** List of identifiers of EdgeGroups */ + edgeGroups: Array; + /** Deployment type to deploy this stack */ + deploymentType: DeploymentType; + /** List of Registries to use for this stack */ + registries?: Array; + /** Uses the manifest's namespaces instead of the default one */ + useManifestNamespaces?: boolean; + /** Pre Pull image */ + prePullImage?: boolean; + /** Retry deploy */ + retryDeploy?: boolean; + /** List of environment variables */ + envVars?: Array; + /** Configuration for stagger updates */ + staggerConfig?: StaggerConfig; +}; + +/** + * Payload for creating an EdgeStack from a string + */ +export type CreateEdgeStackPayload = + | { + method: 'file'; + payload: BasePayload & { + /** File to upload */ + file: File; + /** Optional webhook configuration */ + webhook?: string; + }; + } + | { + method: 'string'; + payload: BasePayload & { + /** Content of the Stack file */ + fileContent: string; + /** Optional webhook configuration */ + webhook?: string; + }; + } + | { + method: 'git'; + payload: BasePayload & { + git: GitFormModel; + relativePathSettings?: RelativePathModel; + }; + }; + +function createEdgeStack({ method, payload }: CreateEdgeStackPayload) { + switch (method) { + case 'file': + return createStackFromFile({ + DeploymentType: payload.deploymentType, + EdgeGroups: payload.edgeGroups, + Name: payload.name, + file: payload.file, + EnvVars: payload.envVars, + PrePullImage: payload.prePullImage, + Registries: payload.registries, + RetryDeploy: payload.retryDeploy, + StaggerConfig: payload.staggerConfig, + UseManifestNamespaces: payload.useManifestNamespaces, + 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, + }); + case 'string': + return createStackFromFileContent({ + 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, + stackFileContent: payload.fileContent, + webhook: payload.webhook, + }); + default: + throw new Error('Invalid method'); + } +} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts deleted file mode 100644 index ab743b550..000000000 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromFileContent.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useMutation, useQueryClient } from 'react-query'; - -import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { withError, withInvalidate } from '@/react-tools/react-query'; -import { RegistryId } from '@/react/portainer/registries/types'; - -import { EdgeGroup } from '../../edge-groups/types'; -import { DeploymentType, EdgeStack } from '../types'; - -import { buildUrl } from './buildUrl'; -import { queryKeys } from './query-keys'; - -export function useCreateEdgeStackFromFileContent() { - const queryClient = useQueryClient(); - - return useMutation(createEdgeStackFromFileContent, { - ...withError('Failed creating Edge stack'), - ...withInvalidate(queryClient, [queryKeys.base()]), - }); -} - -interface FileContentPayload { - name: string; - stackFileContent: string; - edgeGroups: EdgeGroup['Id'][]; - deploymentType: DeploymentType; - registries?: RegistryId[]; - useManifestNamespaces?: boolean; - prePullImage?: boolean; - dryRun?: boolean; -} - -export async function createEdgeStackFromFileContent({ - dryRun, - ...payload -}: FileContentPayload) { - try { - const { data } = await axios.post( - buildUrl(undefined, 'create/string'), - payload, - { - params: { dryrun: dryRun ? 'true' : 'false' }, - } - ); - return data; - } catch (e) { - throw parseAxiosError(e as Error); - } -} diff --git a/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts b/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts deleted file mode 100644 index 913b0231b..000000000 --- a/app/react/edge/edge-stacks/queries/useCreateEdgeStackFromGit.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { useMutation, useQueryClient } from 'react-query'; - -import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { withError, withInvalidate } from '@/react-tools/react-query'; -import { AutoUpdateModel } from '@/react/portainer/gitops/types'; -import { Pair } from '@/react/portainer/settings/types'; -import { RegistryId } from '@/react/portainer/registries/types'; -import { GitCredential } from '@/react/portainer/account/git-credentials/types'; - -import { DeploymentType, EdgeStack } from '../types'; -import { EdgeGroup } from '../../edge-groups/types'; - -import { buildUrl } from './buildUrl'; -import { queryKeys } from './query-keys'; - -export function useCreateEdgeStackFromGit() { - const queryClient = useQueryClient(); - - return useMutation(createEdgeStackFromGit, { - ...withError('Failed creating Edge stack'), - ...withInvalidate(queryClient, [queryKeys.base()]), - }); -} - -/** - * Represents the payload for creating an edge stack from a Git repository. - */ -interface GitPayload { - /** Name of the stack. */ - name: string; - /** URL of a Git repository hosting the Stack file. */ - repositoryURL: string; - /** Reference name of a Git repository hosting the Stack file. */ - repositoryReferenceName?: string; - /** Use basic authentication to clone the Git repository. */ - repositoryAuthentication?: boolean; - /** Username used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryUsername?: string; - /** Password used in basic authentication. Required when RepositoryAuthentication is true. */ - repositoryPassword?: string; - /** GitCredentialID used to identify the bound git credential. */ - repositoryGitCredentialID?: GitCredential['id']; - /** Path to the Stack file inside the Git repository. */ - filePathInRepository?: string; - /** List of identifiers of EdgeGroups. */ - edgeGroups: Array; - /** Deployment type to deploy this stack. */ - deploymentType: DeploymentType; - /** List of Registries to use for this stack. */ - registries?: RegistryId[]; - /** Uses the manifest's namespaces instead of the default one. */ - useManifestNamespaces?: boolean; - /** Pre-pull image. */ - prePullImage?: boolean; - /** Retry deploy. */ - retryDeploy?: boolean; - /** TLSSkipVerify skips SSL verification when cloning the Git repository. */ - tLSSkipVerify?: boolean; - /** Optional GitOps update configuration. */ - autoUpdate?: AutoUpdateModel; - /** Whether the stack supports relative path volume. */ - supportRelativePath?: boolean; - /** Local filesystem path. */ - filesystemPath?: string; - /** Whether the edge stack supports per device configs. */ - supportPerDeviceConfigs?: boolean; - /** Per device configs match type. */ - perDeviceConfigsMatchType?: 'file' | 'dir'; - /** Per device configs group match type. */ - perDeviceConfigsGroupMatchType?: 'file' | 'dir'; - /** Per device configs path. */ - perDeviceConfigsPath?: string; - /** List of environment variables. */ - envVars?: Pair[]; - /** Configuration for stagger updates. */ - staggerConfig?: EdgeStaggerConfig; -} -/** - * Represents the staggered updates configuration. - */ -interface EdgeStaggerConfig { - /** Stagger option for updates. */ - staggerOption: EdgeStaggerOption; - /** Stagger parallel option for updates. */ - staggerParallelOption: EdgeStaggerParallelOption; - /** Device number for updates. */ - deviceNumber: number; - /** Starting device number for updates. */ - deviceNumberStartFrom: number; - /** Increment value for device numbers during updates. */ - deviceNumberIncrementBy: number; - /** Timeout for updates (in minutes). */ - timeout: string; - /** Update delay (in minutes). */ - updateDelay: string; - /** Action to take in case of update failure. */ - updateFailureAction: EdgeUpdateFailureAction; -} - -/** EdgeStaggerOption represents an Edge stack stagger option */ -enum EdgeStaggerOption { - /** AllAtOnce represents a staggered deployment where all nodes are updated at once */ - AllAtOnce = 1, - /** OneByOne represents a staggered deployment where nodes are updated with parallel setting */ - Parallel, -} - -/** EdgeStaggerParallelOption represents an Edge stack stagger parallel option */ -enum EdgeStaggerParallelOption { - /** Fixed represents a staggered deployment where nodes are updated with a fixed number of nodes in parallel */ - Fixed = 1, - /** Incremental represents a staggered deployment where nodes are updated with an incremental number of nodes in parallel */ - Incremental, -} - -/** EdgeUpdateFailureAction represents an Edge stack update failure action */ -enum EdgeUpdateFailureAction { - /** Continue represents that stagger update will continue regardless of whether the endpoint update status */ - Continue = 1, - /** Pause represents that stagger update will pause when the endpoint update status is failed */ - Pause, - /** Rollback represents that stagger update will rollback as long as one endpoint update status is failed */ - Rollback, -} - -export async function createEdgeStackFromGit({ - dryRun, - ...payload -}: GitPayload & { dryRun?: boolean }) { - try { - const { data } = await axios.post( - buildUrl(undefined, 'create/repository'), - payload, - { - params: { dryrun: dryRun ? 'true' : 'false' }, - } - ); - return data; - } catch (e) { - throw parseAxiosError(e as Error); - } -} diff --git a/app/react/edge/edge-stacks/queries/useParseRegistries.ts b/app/react/edge/edge-stacks/queries/useParseRegistries.ts new file mode 100644 index 000000000..a4a49fa25 --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useParseRegistries.ts @@ -0,0 +1,44 @@ +import { useMutation } from 'react-query'; + +import { withError } from '@/react-tools/react-query'; +import { RegistryId } from '@/react/portainer/registries/types'; +import axios, { + json2formData, + parseAxiosError, +} from '@/portainer/services/axios'; + +import { buildUrl } from './buildUrl'; + +export function useParseRegistries() { + return useMutation(parseRegistries, { + ...withError('Failed parsing registries'), + }); +} + +export async function parseRegistries(props: { + file?: File; + fileContent?: string; +}) { + if (!props.file && !props.fileContent) { + throw new Error('File or fileContent must be provided'); + } + + let currentFile = props.file; + if (!props.file && props.fileContent) { + currentFile = new File([props.fileContent], 'registries.yml'); + } + try { + const { data } = await axios.post>( + buildUrl(undefined, 'parse_registries'), + json2formData({ file: currentFile }), + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index 351942471..8e6f7e30e 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -1,6 +1,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { AutoUpdateResponse, + RelativePathModel, RepoConfigResponse, } from '@/react/portainer/gitops/types'; import { RegistryId } from '@/react/portainer/registries/types'; @@ -9,6 +10,13 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; import { EdgeGroup } from '../edge-groups/types'; +export { + type StaggerConfig, + StaggerOption, + StaggerParallelOption, + UpdateFailureAction, +} from './components/StaggerFieldset.types'; + export enum StatusType { /** Pending represents a pending edge stack */ Pending, @@ -62,7 +70,7 @@ export enum DeploymentType { Kubernetes, } -export type EdgeStack = { +export type EdgeStack = RelativePathModel & { Id: number; Name: string; Status: { [key: EnvironmentId]: EdgeStackStatus }; @@ -89,10 +97,6 @@ export type EdgeStack = { EnvVars?: EnvVar[]; SupportRelativePath: boolean; FilesystemPath?: string; - SupportPerDeviceConfigs?: boolean; - PerDeviceConfigsPath?: string; - PerDeviceConfigsMatchType?: string; - PerDeviceConfigsGroupMatchType?: string; }; export enum EditorType { diff --git a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx index e19895519..a0121a6f7 100644 --- a/app/react/edge/templates/AppTemplatesView/DeployForm.tsx +++ b/app/react/edge/templates/AppTemplatesView/DeployForm.tsx @@ -23,7 +23,7 @@ import { EdgeGroup } from '../../edge-groups/types'; import { DeploymentType, EdgeStack } from '../../edge-stacks/types'; import { useEdgeStacks } from '../../edge-stacks/queries/useEdgeStacks'; import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups'; -import { useCreateEdgeStackFromGit } from '../../edge-stacks/queries/useCreateEdgeStackFromGit'; +import { useCreateEdgeStack } from '../../edge-stacks/queries/useCreateEdgeStack/useCreateEdgeStack'; import { EnvVarsFieldset } from './EnvVarsFieldset'; @@ -70,7 +70,7 @@ function DeployForm({ unselect: () => void; }) { const router = useRouter(); - const mutation = useCreateEdgeStackFromGit(); + const mutation = useCreateEdgeStack(); const edgeStacksQuery = useEdgeStacks(); const edgeGroupsQuery = useEdgeGroups({ select: (groups) => @@ -139,15 +139,21 @@ function DeployForm({ function handleSubmit(values: FormValues) { return mutation.mutate( { - name: values.name, - edgeGroups: values.edgeGroupIds, - deploymentType: DeploymentType.Compose, - repositoryURL: template.Repository.url, - filePathInRepository: template.Repository.stackfile, - envVars: Object.entries(values.envVars).map(([name, value]) => ({ - name, - value, - })), + method: 'git', + payload: { + name: values.name, + edgeGroups: values.edgeGroupIds, + deploymentType: DeploymentType.Compose, + + envVars: Object.entries(values.envVars).map(([name, value]) => ({ + name, + value, + })), + git: { + RepositoryURL: template.Repository.url, + ComposeFilePathInRepository: template.Repository.stackfile, + }, + }, }, { onSuccess() { diff --git a/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx b/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx index 47bed8113..20d09aab2 100644 --- a/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx +++ b/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx @@ -6,6 +6,7 @@ import { notifySuccess } from '@/portainer/services/notifications'; import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation'; import { Platform } from '@/react/portainer/templates/types'; import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile'; +import { getDefaultEdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; import { editor } from '@@/BoxSelector/common-options/build-methods'; @@ -47,6 +48,7 @@ export function CreateTemplateForm() { RepositoryURLValid: true, TLSSkipVerify: false, }, + EdgeSettings: getDefaultEdgeTemplateSettings(), }; return ( diff --git a/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx b/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx new file mode 100644 index 000000000..c1f9d9ad1 --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx @@ -0,0 +1,81 @@ +import { FormikErrors } from 'formik'; +import { SetStateAction } from 'react'; + +import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset'; +import { PrivateRegistryFieldsetWrapper } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper'; +import { PrePullToggle } from '@/react/edge/edge-stacks/components/PrePullToggle'; +import { RetryDeployToggle } from '@/react/edge/edge-stacks/components/RetryDeployToggle'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; +import { GitFormModel } from '@/react/portainer/gitops/types'; + +import { FormSection } from '@@/form-components/FormSection'; + +export function EdgeSettingsFieldset({ + values, + setValues, + errors, + gitConfig, + fileValues, + setFieldError, +}: { + values: EdgeTemplateSettings; + setValues: (values: SetStateAction) => void; + errors?: FormikErrors; + gitConfig?: GitFormModel; + setFieldError: (field: string, message: string) => void; + fileValues: { + fileContent?: string; + file?: File; + }; +}) { + const isGit = !!gitConfig; + return ( + <> + {isGit && ( + + + setValues((values) => ({ + ...values, + RelativePathSettings: { + ...values.RelativePathSettings, + ...newValues, + }, + })) + } + /> + + )} + + + setValues((values) => ({ + ...values, + PrivateRegistryId: registryId, + })) + } + values={fileValues} + onFieldError={(error) => setFieldError('Edge?.Registries', error)} + error={errors?.PrivateRegistryId} + isGit={isGit} + /> + + + setValues((values) => ({ ...values, PrePullImage: value })) + } + value={values.PrePullImage} + /> + + + setValues((values) => ({ ...values, RetryDeploy: value })) + } + value={values.RetryDeploy} + /> + + ); +} diff --git a/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts b/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts new file mode 100644 index 000000000..b6e0dc462 --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts @@ -0,0 +1,19 @@ +import { SchemaOf, boolean, mixed, number, object } from 'yup'; + +import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +export function edgeFieldsetValidation(): SchemaOf { + if (!isBE) { + return mixed().default(undefined) as SchemaOf; + } + + return object({ + RelativePathSettings: relativePathValidation(), + PrePullImage: boolean().default(false), + RetryDeploy: boolean().default(false), + PrivateRegistryId: number().default(undefined), + StaggerConfig: mixed(), + }); +} diff --git a/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx index 26142e735..b09aecf6b 100644 --- a/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx +++ b/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx @@ -1,4 +1,4 @@ -import { Form, useFormikContext } from 'formik'; +import { Form, FormikErrors, useFormikContext } from 'formik'; import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields'; import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; @@ -10,6 +10,8 @@ import { isTemplateVariablesEnabled, } from '@/react/portainer/custom-templates/components/utils'; import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; +import { applySetStateAction } from '@/react-tools/apply-set-state-action'; import { BoxSelector } from '@@/BoxSelector'; import { WebEditorForm, usePreventExit } from '@@/WebEditorForm'; @@ -23,6 +25,7 @@ import { } from '@@/BoxSelector/common-options/build-methods'; import { FormValues, Method, buildMethods } from './types'; +import { EdgeSettingsFieldset } from './EdgeSettingsFieldset'; export function InnerForm({ isLoading }: { isLoading: boolean }) { const { @@ -41,6 +44,8 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) { values.FileContent, values.Method === editor.value && !isSubmitting ); + + const isGit = values.Method === git.value; return (
)} + {isTemplateVariablesEnabled && ( + setFieldValue('Variables', values)} + isVariablesNamesFromParent={values.Method === editor.value} + errors={errors.Variables} + /> + )} + {values.Method === git.value && ( )} - {isTemplateVariablesEnabled && ( - setFieldValue('Variables', values)} - isVariablesNamesFromParent={values.Method === editor.value} - errors={errors.Variables} + {values.EdgeSettings && ( + + setValues((values) => ({ + ...values, + EdgeSettings: applySetStateAction( + edgeSetValues, + values.EdgeSettings + ), + })) + } + gitConfig={isGit ? values.Git : undefined} + fileValues={{ + fileContent: values.FileContent, + file: values.File, + }} + values={values.EdgeSettings} + errors={errors.EdgeSettings as FormikErrors} + setFieldError={setFieldError} /> )} diff --git a/app/react/edge/templates/custom-templates/CreateView/types.ts b/app/react/edge/templates/custom-templates/CreateView/types.ts index 48c34719c..800045e58 100644 --- a/app/react/edge/templates/custom-templates/CreateView/types.ts +++ b/app/react/edge/templates/custom-templates/CreateView/types.ts @@ -3,6 +3,7 @@ import { type Values as CommonFieldsValues } from '@/react/portainer/custom-temp import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { Platform } from '@/react/portainer/templates/types'; import { GitFormModel } from '@/react/portainer/gitops/types'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; import { editor, @@ -22,4 +23,5 @@ export interface FormValues extends CommonFieldsValues { File: File | undefined; Git: GitFormModel; Variables: DefinitionFieldValues; + EdgeSettings?: EdgeTemplateSettings; } diff --git a/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx b/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx index b7e0fad6f..49c33c087 100644 --- a/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx +++ b/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx @@ -18,6 +18,7 @@ import { } from '@@/BoxSelector/common-options/build-methods'; import { buildMethods } from './types'; +import { edgeFieldsetValidation } from './EdgeSettingsFieldset.validation'; export function useValidation() { const { user } = useCurrentUser(); @@ -51,6 +52,7 @@ export function useValidation() { then: () => buildGitValidationSchema(gitCredentialsQuery.data || []), }), Variables: variablesValidation(), + EdgeSettings: edgeFieldsetValidation(), }).concat( commonFieldsValidation({ templates: customTemplatesQuery.data }) ), diff --git a/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx b/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx index 4e81f4048..b88b10f2a 100644 --- a/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx +++ b/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx @@ -38,6 +38,7 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) { FileContent: fileQuery.data || '', Git: template.GitConfig ? toGitFormModel(template.GitConfig) : undefined, + EdgeSettings: template.EdgeSettings, }; return ( @@ -72,6 +73,7 @@ export function EditTemplateForm({ template }: { template: CustomTemplate }) { Note: values.Note, Platform: values.Platform, Variables: values.Variables, + EdgeSettings: values.EdgeSettings, ...values.Git, }, { diff --git a/app/react/edge/templates/custom-templates/EditView/EditView.tsx b/app/react/edge/templates/custom-templates/EditView/EditView.tsx index 04302595b..b355bb9ea 100644 --- a/app/react/edge/templates/custom-templates/EditView/EditView.tsx +++ b/app/react/edge/templates/custom-templates/EditView/EditView.tsx @@ -12,9 +12,9 @@ import { EditTemplateForm } from './EditTemplateForm'; export function EditView() { const router = useRouter(); const { - params: { id }, + params: { id: templateId }, } = useCurrentStateAndParams(); - const customTemplateQuery = useCustomTemplate(id); + const customTemplateQuery = useCustomTemplate(templateId); useEffect(() => { if (customTemplateQuery.data && !customTemplateQuery.data.EdgeTemplate) { diff --git a/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx index c7358c80b..8836e0e67 100644 --- a/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx +++ b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx @@ -1,4 +1,4 @@ -import { Form, useFormikContext } from 'formik'; +import { Form, FormikErrors, useFormikContext } from 'formik'; import { RefreshCw } from 'lucide-react'; import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields'; @@ -11,12 +11,16 @@ import { isTemplateVariablesEnabled, } from '@/react/portainer/custom-templates/components/utils'; import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector'; +import { applySetStateAction } from '@/react-tools/apply-set-state-action'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; import { WebEditorForm, usePreventExit } from '@@/WebEditorForm'; import { FormActions } from '@@/form-components/FormActions'; import { Button } from '@@/buttons'; import { FormError } from '@@/form-components/FormError'; +import { EdgeSettingsFieldset } from '../CreateView/EdgeSettingsFieldset'; + import { FormValues } from './types'; export function InnerForm({ @@ -74,7 +78,11 @@ export function InnerForm({ value={gitFileContent || values.FileContent} onChange={handleChangeFileContent} yaml - placeholder="Define or paste the content of your docker compose file here" + placeholder={ + gitFileContent + ? 'Preview of the file from git repository' + : 'Define or paste the content of your docker compose file here' + } error={errors.FileContent} readonly={isEditorReadonly} > @@ -91,6 +99,15 @@ export function InnerForm({

+ {isTemplateVariablesEnabled && ( + setFieldValue('Variables', values)} + isVariablesNamesFromParent={!isEditorReadonly} + errors={errors.Variables} + /> + )} + {values.Git && ( <> )} - {isTemplateVariablesEnabled && ( - setFieldValue('Variables', values)} - isVariablesNamesFromParent={!isEditorReadonly} - errors={errors.Variables} + {values.EdgeSettings && ( + + setFieldValue( + 'EdgeSettings', + applySetStateAction(edgeValues, values.EdgeSettings) + ) + } + gitConfig={values.Git} + fileValues={{ + fileContent: values.FileContent, + }} + values={values.EdgeSettings} + errors={errors.EdgeSettings as FormikErrors} + setFieldError={setFieldError} /> )} diff --git a/app/react/edge/templates/custom-templates/EditView/types.ts b/app/react/edge/templates/custom-templates/EditView/types.ts index a847979c1..d9c257e1d 100644 --- a/app/react/edge/templates/custom-templates/EditView/types.ts +++ b/app/react/edge/templates/custom-templates/EditView/types.ts @@ -3,6 +3,7 @@ import { DefinitionFieldValues } from '@/react/portainer/custom-templates/compon import { Platform } from '@/react/portainer/templates/types'; import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields'; import { GitFormModel } from '@/react/portainer/gitops/types'; +import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; export interface FormValues extends CommonFieldsValues { Platform: Platform; @@ -10,4 +11,5 @@ export interface FormValues extends CommonFieldsValues { FileContent: string; Git?: GitFormModel; Variables: DefinitionFieldValues; + EdgeSettings?: EdgeTemplateSettings; } diff --git a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx index 7df3ec116..60dcc2b66 100644 --- a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx +++ b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx @@ -11,6 +11,8 @@ import { useCurrentUser } from '@/react/hooks/useUser'; import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates'; import { Platform } from '@/react/portainer/templates/types'; +import { edgeFieldsetValidation } from '../CreateView/EdgeSettingsFieldset.validation'; + export function useValidation( currentTemplateId: CustomTemplate['Id'], isGit: boolean @@ -40,6 +42,7 @@ export function useValidation( ? buildGitValidationSchema(gitCredentialsQuery.data || []) : mixed(), Variables: variablesValidation(), + EdgeSettings: edgeFieldsetValidation(), }).concat( commonFieldsValidation({ templates: customTemplatesQuery.data, diff --git a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx b/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx index 64fb198d4..099220f56 100644 --- a/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx +++ b/app/react/portainer/gitops/AuthFieldset/AuthFieldset.tsx @@ -45,7 +45,7 @@ export function AuthFieldset({ label="Authentication" labelClass="col-sm-3 col-lg-2" name="authentication" - checked={value.RepositoryAuthentication} + checked={value.RepositoryAuthentication || false} onChange={(value) => handleChange({ RepositoryAuthentication: value }) } diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx index 284559d68..5a34d65cd 100644 --- a/app/react/portainer/gitops/GitForm.tsx +++ b/app/react/portainer/gitops/GitForm.tsx @@ -113,7 +113,7 @@ export function GitForm({
handleChange({ TLSSkipVerify: value })} name="TLSSkipVerify" tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate." diff --git a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx index f42ee66db..e5acbbe65 100644 --- a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx +++ b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx @@ -1,9 +1,6 @@ import { useCallback } from 'react'; -import { - GitFormModel, - RelativePathModel, -} from '@/react/portainer/gitops/types'; +import { GitFormModel } from '@/react/portainer/gitops/types'; import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector'; import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils'; import { useValidation } from '@/react/portainer/gitops/RelativePathFieldset/useValidation'; @@ -13,6 +10,8 @@ import { TextTip } from '@@/Tip/TextTip'; import { FormControl } from '@@/form-components/FormControl'; import { Input, Select } from '@@/form-components/Input'; +import { RelativePathModel, getPerDevConfigsFilterType } from './types'; + interface Props { value: RelativePathModel; gitModel?: GitFormModel; @@ -156,7 +155,9 @@ export function RelativePathFieldset({ value={value.PerDeviceConfigsMatchType} onChange={(e) => innerOnChange({ - PerDeviceConfigsMatchType: e.target.value, + PerDeviceConfigsMatchType: getPerDevConfigsFilterType( + e.target.value + ), }) } options={[ @@ -186,7 +187,8 @@ export function RelativePathFieldset({ value={value.PerDeviceConfigsGroupMatchType} onChange={(e) => innerOnChange({ - PerDeviceConfigsGroupMatchType: e.target.value, + PerDeviceConfigsGroupMatchType: + getPerDevConfigsFilterType(e.target.value), }) } options={[ diff --git a/app/react/portainer/gitops/RelativePathFieldset/types.ts b/app/react/portainer/gitops/RelativePathFieldset/types.ts new file mode 100644 index 000000000..be1a08d99 --- /dev/null +++ b/app/react/portainer/gitops/RelativePathFieldset/types.ts @@ -0,0 +1,37 @@ +export function getDefaultRelativePathModel(): RelativePathModel { + return { + SupportRelativePath: false, + FilesystemPath: '', + PerDeviceConfigsGroupMatchType: '', + PerDeviceConfigsMatchType: '', + PerDeviceConfigsPath: '', + SupportPerDeviceConfigs: false, + }; +} + +export interface RelativePathModel { + SupportRelativePath: boolean; + FilesystemPath: string; + SupportPerDeviceConfigs: boolean; + PerDeviceConfigsPath: string; + PerDeviceConfigsMatchType: PerDevConfigsFilterType; + PerDeviceConfigsGroupMatchType: PerDevConfigsFilterType; +} + +export type PerDevConfigsFilterType = 'file' | 'dir' | ''; + +function isPerDevConfigsFilterType( + type: string +): type is PerDevConfigsFilterType { + return ['file', 'dir'].includes(type); +} + +export function getPerDevConfigsFilterType( + type: string +): PerDevConfigsFilterType { + if (isPerDevConfigsFilterType(type)) { + return type; + } + + return ''; +} diff --git a/app/react/portainer/gitops/RelativePathFieldset/validation.ts b/app/react/portainer/gitops/RelativePathFieldset/validation.ts index 351e188bf..6f2d16540 100644 --- a/app/react/portainer/gitops/RelativePathFieldset/validation.ts +++ b/app/react/portainer/gitops/RelativePathFieldset/validation.ts @@ -1,6 +1,6 @@ -import { boolean, object, SchemaOf, string } from 'yup'; +import { boolean, mixed, object, SchemaOf, string } from 'yup'; -import { RelativePathModel } from '@/react/portainer/gitops/types'; +import { PerDevConfigsFilterType, RelativePathModel } from './types'; export function relativePathValidation(): SchemaOf { return object({ @@ -18,7 +18,11 @@ export function relativePathValidation(): SchemaOf { then: string().required('Directory is required'), }) .default(''), - PerDeviceConfigsMatchType: string().oneOf(['', 'file', 'dir']), - PerDeviceConfigsGroupMatchType: string().oneOf(['', 'file', 'dir']), + PerDeviceConfigsMatchType: mixed() + .oneOf(['', 'file', 'dir']) + .default(''), + PerDeviceConfigsGroupMatchType: mixed() + .oneOf(['', 'file', 'dir']) + .default(''), }); } diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 41b0f9eff..72d6552c8 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -1,4 +1,6 @@ export type AutoUpdateMechanism = 'Webhook' | 'Interval'; +export { type RelativePathModel } from './RelativePathFieldset/types'; + export interface AutoUpdateResponse { /* Auto update interval */ Interval: string; @@ -37,7 +39,7 @@ export type AutoUpdateModel = { }; export type GitCredentialsModel = { - RepositoryAuthentication: boolean; + RepositoryAuthentication?: boolean; RepositoryUsername?: string; RepositoryPassword?: string; RepositoryGitCredentialID?: number; @@ -54,13 +56,12 @@ export interface GitFormModel extends GitAuthModel { RepositoryURL: string; RepositoryURLValid?: boolean; ComposeFilePathInRepository: string; - RepositoryAuthentication: boolean; RepositoryReferenceName?: string; AdditionalFiles?: string[]; SaveCredential?: boolean; NewCredentialName?: string; - TLSSkipVerify: boolean; + TLSSkipVerify?: boolean; /** * Auto update @@ -70,15 +71,6 @@ export interface GitFormModel extends GitAuthModel { AutoUpdate?: AutoUpdateModel; } -export interface RelativePathModel { - SupportRelativePath: boolean; - FilesystemPath?: string; - SupportPerDeviceConfigs?: boolean; - PerDeviceConfigsPath?: string; - PerDeviceConfigsMatchType?: string; - PerDeviceConfigsGroupMatchType?: string; -} - export function toGitFormModel(response?: RepoConfigResponse): GitFormModel { if (!response) { return { diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index f2523259c..6f5126855 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -41,7 +41,7 @@ export interface LDAPSettings { export interface Pair { name: string; - value: string; + value?: string; } export interface OpenAMTConfiguration { diff --git a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx index 69e886c9f..3fe1fe278 100644 --- a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx +++ b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesListItem.tsx @@ -46,7 +46,7 @@ export function CustomTemplatesListItem({ props={{ to: '.edit', params: { - templateId: template.Id, + id: template.Id, }, }} icon={Edit} diff --git a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts index 56c8f9387..0ad51be1d 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts @@ -12,7 +12,10 @@ import { import { StackType } from '@/react/common/stacks/types'; import { FormValues } from '@/react/edge/templates/custom-templates/CreateView/types'; import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; -import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { + CustomTemplate, + EdgeTemplateSettings, +} from '@/react/portainer/templates/custom-templates/types'; import { Platform } from '../../types'; @@ -41,7 +44,18 @@ function createTemplate({ case 'upload': return createTemplateFromFile(values); case 'repository': - return createTemplateFromGit({ ...values, ...Git }); + return createTemplateFromGit({ + ...values, + ...Git, + ...(values.EdgeSettings + ? { + EdgeSettings: { + ...values.EdgeSettings, + ...values.EdgeSettings.RelativePathSettings, + }, + } + : {}), + }); default: throw new Error('Unknown method'); } @@ -69,6 +83,7 @@ interface CustomTemplateFromFileContentPayload { Variables: VariableDefinition[]; /** Indicates if this template is for Edge Stack. */ EdgeTemplate?: boolean; + EdgeSettings?: EdgeTemplateSettings; } async function createTemplateFromText( values: CustomTemplateFromFileContentPayload @@ -103,6 +118,7 @@ interface CustomTemplateFromFilePayload { Variables?: VariableDefinition[]; /** Indicates if this template is for Edge Stack. */ EdgeTemplate?: boolean; + EdgeSettings?: EdgeTemplateSettings; } async function createTemplateFromFile(values: CustomTemplateFromFilePayload) { @@ -121,6 +137,7 @@ async function createTemplateFromFile(values: CustomTemplateFromFilePayload) { File: values.File, Variables: values.Variables, EdgeTemplate: values.EdgeTemplate, + EdgeSettings: values.EdgeSettings, }); const { data } = await axios.post( @@ -157,7 +174,7 @@ interface CustomTemplateFromGitRepositoryPayload { /** Reference name of a Git repository hosting the Stack file. */ RepositoryReferenceName?: string; /** Use basic authentication to clone the Git repository. */ - RepositoryAuthentication: boolean; + RepositoryAuthentication?: boolean; /** Username used in basic authentication when RepositoryAuthentication is true. */ RepositoryUsername?: string; /** Password used in basic authentication when RepositoryAuthentication is true. */ @@ -167,11 +184,12 @@ interface CustomTemplateFromGitRepositoryPayload { /** Definitions of variables in the stack file. */ Variables: VariableDefinition[]; /** Indicates whether to skip SSL verification when cloning the Git repository. */ - TLSSkipVerify: boolean; + TLSSkipVerify?: boolean; /** Indicates if the Kubernetes template is created from a Docker Compose file. */ IsComposeFormat?: boolean; /** Indicates if this template is for Edge Stack. */ EdgeTemplate?: boolean; + EdgeSettings?: EdgeTemplateSettings; } async function createTemplateFromGit( values: CustomTemplateFromGitRepositoryPayload diff --git a/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts index 8d1550343..8ba62774a 100644 --- a/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts @@ -9,7 +9,7 @@ import { import { StackType } from '@/react/common/stacks/types'; import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; -import { CustomTemplate } from '../types'; +import { CustomTemplate, EdgeTemplateSettings } from '../types'; import { Platform } from '../../types'; import { buildUrl } from './build-url'; @@ -75,6 +75,7 @@ interface CustomTemplateUpdatePayload { IsComposeFormat?: boolean; /** EdgeTemplate indicates if this template purpose for Edge Stack */ EdgeTemplate?: boolean; + EdgeSettings?: EdgeTemplateSettings; } async function updateTemplate( diff --git a/app/react/portainer/templates/custom-templates/types.ts b/app/react/portainer/templates/custom-templates/types.ts index 7a951db8d..0b6230860 100644 --- a/app/react/portainer/templates/custom-templates/types.ts +++ b/app/react/portainer/templates/custom-templates/types.ts @@ -2,9 +2,12 @@ import { UserId } from '@/portainer/users/types'; import { StackType } from '@/react/common/stacks/types'; import { ResourceControlResponse } from '../../access-control/types'; -import { RepoConfigResponse } from '../../gitops/types'; +import { RelativePathModel, RepoConfigResponse } from '../../gitops/types'; import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { Platform } from '../types'; +import { RegistryId } from '../../registries/types'; +import { getDefaultRelativePathModel } from '../../gitops/RelativePathFieldset/types'; +import { isBE } from '../../feature-flags/feature-flags.service'; export type CustomTemplate = { Id: number; @@ -87,16 +90,38 @@ export type CustomTemplate = { /** EdgeTemplate indicates if this template purpose for Edge Stack */ EdgeTemplate: boolean; + + EdgeSettings?: EdgeTemplateSettings; +}; + +/** + * EdgeTemplateSettings represents the configuration of a custom template for Edge + */ +export type EdgeTemplateSettings = { + PrePullImage: boolean; + + RetryDeploy: boolean; + + PrivateRegistryId: RegistryId | undefined; + + RelativePathSettings: RelativePathModel; }; export type CustomTemplateFileContent = { FileContent: string; }; -export const CustomTemplateKubernetesType = 3; +export const CustomTemplateKubernetesType = StackType.Kubernetes; -export enum Types { - SWARM = 1, - STANDALONE, - KUBERNETES, +export function getDefaultEdgeTemplateSettings() { + if (!isBE) { + return undefined; + } + + return { + PrePullImage: false, + RetryDeploy: false, + PrivateRegistryId: undefined, + RelativePathSettings: getDefaultRelativePathModel(), + }; }