From 66ca73f98b1b9c4d4b81b3030962417523c37c7f Mon Sep 17 00:00:00 2001 From: cmeng Date: Wed, 11 Oct 2023 18:11:12 +1300 Subject: [PATCH] fix(edge-stack): sync CE code with EE EE-6163 (#10437) --- .../EditEdgeStackForm/GitForm/GitForm.tsx | 13 +- app/react/edge/edge-stacks/types.ts | 6 + .../gitops/ComposePathField/PathSelector.tsx | 10 +- .../RelativePathFieldset.tsx | 251 ++++++++++++++++++ .../RelativePathFieldset/useValidation.ts | 27 ++ .../gitops/RelativePathFieldset/utils.ts | 28 ++ .../gitops/RelativePathFieldset/validation.ts | 24 ++ app/react/portainer/gitops/index.ts | 32 +++ app/react/portainer/gitops/types.ts | 9 + 9 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx create mode 100644 app/react/portainer/gitops/RelativePathFieldset/useValidation.ts create mode 100644 app/react/portainer/gitops/RelativePathFieldset/utils.ts create mode 100644 app/react/portainer/gitops/RelativePathFieldset/validation.ts create mode 100644 app/react/portainer/gitops/index.ts diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx index 7ce0fc6d9..1a4566a12 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx @@ -3,13 +3,18 @@ import { useRouter } from '@uirouter/react'; import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset'; import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel, } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { InfoPanel } from '@/react/portainer/gitops/InfoPanel'; import { RefField } from '@/react/portainer/gitops/RefField'; -import { AutoUpdateModel, GitAuthModel } from '@/react/portainer/gitops/types'; +import { + AutoUpdateModel, + GitAuthModel, + RelativePathModel, +} from '@/react/portainer/gitops/types'; import { baseEdgeStackWebhookUrl, createWebhookId, @@ -28,6 +33,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { EnvironmentType } from '@/react/portainer/environments/types'; import { Registry } from '@/react/portainer/registries/types'; import { useRegistries } from '@/react/portainer/registries/queries/useRegistries'; +import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset'; +import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils'; import { LoadingButton } from '@@/buttons'; import { FormSection } from '@@/form-components/FormSection'; @@ -53,6 +60,7 @@ interface FormValues { authentication: GitAuthModel; envVars: EnvVar[]; privateRegistryId?: Registry['Id']; + relativePath: RelativePathModel; } export function GitForm({ stack }: { stack: EdgeStack }) { @@ -73,6 +81,7 @@ export function GitForm({ stack }: { stack: EdgeStack }) { autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), refName: stack.GitConfig.ReferenceName, authentication: parseAuthResponse(stack.GitConfig.Authentication), + relativePath: parseRelativePathResponse(stack), envVars: stack.EnvVars || [], }; @@ -270,6 +279,8 @@ function InnerForm({ errors={errors.authentication} /> + {isBE && } + setFieldValue('envVars', value)} values={values.envVars} diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index 42319d341..8875c73dd 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -83,6 +83,12 @@ export type EdgeStack = { Webhook?: string; StackFileVersion?: number; EnvVars?: EnvVar[]; + SupportRelativePath: boolean; + FilesystemPath?: string; + SupportPerDeviceConfigs?: boolean; + PerDeviceConfigsPath?: string; + PerDeviceConfigsMatchType?: string; + PerDeviceConfigsGroupMatchType?: string; }; export enum EditorType { diff --git a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx index fb97ceea0..76544e46b 100644 --- a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx +++ b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx @@ -1,3 +1,4 @@ +import { ChangeEvent } from 'react'; import { Combobox, ComboboxInput, @@ -6,7 +7,6 @@ import { ComboboxPopover, } from '@reach/combobox'; import '@reach/combobox/styles.css'; -import { ChangeEvent } from 'react'; import clsx from 'clsx'; import { useSearch } from '@/react/portainer/gitops/queries/useSearch'; @@ -22,11 +22,15 @@ export function PathSelector({ onChange, placeholder, model, + dirOnly, + readOnly, }: { value: string; onChange(value: string): void; placeholder: string; model: GitFormModel; + dirOnly?: boolean; + readOnly?: boolean; }) { const [searchTerm, setSearchTerm] = useDebounce(value, onChange); @@ -36,6 +40,7 @@ export function PathSelector({ keyword: searchTerm, reference: model.RepositoryReferenceName, tlsSkipVerify: model.TLSSkipVerify, + dirOnly, ...creds, }; const enabled = Boolean( @@ -51,10 +56,11 @@ export function PathSelector({ data-cy="component-gitComposeInput" > {searchResults && searchResults.length > 0 && ( diff --git a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx new file mode 100644 index 000000000..d51a81655 --- /dev/null +++ b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx @@ -0,0 +1,251 @@ +import { useCallback } from 'react'; + +import { + GitFormModel, + RelativePathModel, +} 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'; + +import { SwitchField } from '@@/form-components/SwitchField'; +import { TextTip } from '@@/Tip/TextTip'; +import { FormControl } from '@@/form-components/FormControl'; +import { Input, Select } from '@@/form-components/Input'; + +interface Props { + value: RelativePathModel; + gitModel?: GitFormModel; + onChange?: (value: Partial) => void; + readonly?: boolean; +} + +export function RelativePathFieldset({ + value, + gitModel, + onChange, + readonly, +}: Props) { + const innerOnChange = useCallback( + (value: Partial) => onChange && onChange(value), + [onChange] + ); + + const { errors } = useValidation(value); + + return ( + <> +
+
+ + 'Gitops Edge Configuration' requires relative path volumes + to be enabled first, as it uses this feature as the base mechanism. + +
+
+ +
+
+ innerOnChange({ SupportRelativePath: value })} + /> +
+
+ + {value.SupportRelativePath && ( + <> +
+
+ + For relative path volumes use with Docker Swarm, you must have a + network filesystem which all of your nodes can access. + +
+
+ +
+
+ + + innerOnChange({ FilesystemPath: e.target.value }) + } + /> + +
+
+ +
+
+ + When enabled, corresponding Edge ID will be passed through as an + environment variable: PORTAINER_EDGE_ID. + +
+
+ +
+
+ + innerOnChange({ SupportPerDeviceConfigs: value }) + } + /> +
+
+ + {value.SupportPerDeviceConfigs && ( + <> +
+
+ + Specify the directory name where your configuration will be + located. This will allow you to manage device configuration + settings with a Git repo as your template. + +
+
+ +
+
+ + + innerOnChange({ PerDeviceConfigsPath: value }) + } + placeholder="config" + model={gitModel || dummyGitForm} + readOnly={readonly} + dirOnly + /> + +
+
+ +
+
+ + Select which rule to use when matching configuration with + Portainer Edge ID either on a per-device basis or group-wide + with an Edge Group. Only configurations that match the + selected rule will be accessible through their corresponding + paths. Deployments that rely on accessing the configuration + may experience errors. + +
+
+ +
+
+ + + innerOnChange({ + PerDeviceConfigsGroupMatchType: e.target.value, + }) + } + options={[ + { + label: '', + value: '', + }, + { + label: 'Match file name with Edge Group', + value: 'file', + }, + { + label: 'Match folder name with Edge Group', + value: 'dir', + }, + ]} + disabled={readonly} + /> + +
+
+ +
+
+ +
+ You can use it as an environment variable with an image:{' '} + myapp:${PORTAINER_EDGE_ID} or{' '} + myapp:${PORTAINER_EDGE_GROUP}. You + can also use it with the relative path for volumes:{' '} + + ./config/${PORTAINER_EDGE_ID}:/myapp/config + {' '} + or{' '} + + ./config/${PORTAINER_EDGE_GROUP}:/myapp/groupconfig + + . More documentation can be found{' '} + + here + + . +
+
+
+
+ + )} + + )} + + ); +} diff --git a/app/react/portainer/gitops/RelativePathFieldset/useValidation.ts b/app/react/portainer/gitops/RelativePathFieldset/useValidation.ts new file mode 100644 index 000000000..01d61c0a7 --- /dev/null +++ b/app/react/portainer/gitops/RelativePathFieldset/useValidation.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { FormikErrors, yupToFormErrors } from 'formik'; + +import { RelativePathModel } from '@/react/portainer/gitops/types'; +import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation'; + +export function useValidation(value: RelativePathModel) { + const [errors, setErrors] = useState>({}); + + useEffect(() => { + async function valide() { + try { + await relativePathValidation().validate(value, { + strict: true, + abortEarly: false, + }); + setErrors({}); + } catch (error) { + setErrors(yupToFormErrors(error)); + } + } + + valide(); + }, [value]); + + return { errors }; +} diff --git a/app/react/portainer/gitops/RelativePathFieldset/utils.ts b/app/react/portainer/gitops/RelativePathFieldset/utils.ts new file mode 100644 index 000000000..b13358b7e --- /dev/null +++ b/app/react/portainer/gitops/RelativePathFieldset/utils.ts @@ -0,0 +1,28 @@ +import { EdgeStack } from '@/react/edge/edge-stacks/types'; + +import { GitFormModel, RelativePathModel } from '../types'; + +export function parseRelativePathResponse(stack: EdgeStack): RelativePathModel { + return { + SupportRelativePath: stack.SupportRelativePath, + FilesystemPath: stack.FilesystemPath, + SupportPerDeviceConfigs: stack.SupportPerDeviceConfigs, + PerDeviceConfigsMatchType: stack.PerDeviceConfigsMatchType, + PerDeviceConfigsGroupMatchType: stack.PerDeviceConfigsGroupMatchType, + PerDeviceConfigsPath: stack.PerDeviceConfigsPath, + }; +} + +export const dummyGitForm: GitFormModel = { + RepositoryURL: '', + RepositoryURLValid: false, + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + AdditionalFiles: [], + RepositoryReferenceName: '', + ComposeFilePathInRepository: '', + NewCredentialName: '', + SaveCredential: false, + TLSSkipVerify: false, +}; diff --git a/app/react/portainer/gitops/RelativePathFieldset/validation.ts b/app/react/portainer/gitops/RelativePathFieldset/validation.ts new file mode 100644 index 000000000..351e188bf --- /dev/null +++ b/app/react/portainer/gitops/RelativePathFieldset/validation.ts @@ -0,0 +1,24 @@ +import { boolean, object, SchemaOf, string } from 'yup'; + +import { RelativePathModel } from '@/react/portainer/gitops/types'; + +export function relativePathValidation(): SchemaOf { + return object({ + SupportRelativePath: boolean().default(false), + FilesystemPath: string() + .when(['SupportRelativePath'], { + is: true, + then: string().required('Local filesystem path is required'), + }) + .default(''), + SupportPerDeviceConfigs: boolean().default(false), + PerDeviceConfigsPath: string() + .when(['SupportPerDeviceConfigs'], { + is: true, + then: string().required('Directory is required'), + }) + .default(''), + PerDeviceConfigsMatchType: string().oneOf(['', 'file', 'dir']), + PerDeviceConfigsGroupMatchType: string().oneOf(['', 'file', 'dir']), + }); +} diff --git a/app/react/portainer/gitops/index.ts b/app/react/portainer/gitops/index.ts new file mode 100644 index 000000000..e99f7c752 --- /dev/null +++ b/app/react/portainer/gitops/index.ts @@ -0,0 +1,32 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector'; +import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset'; + +export const ngModule = angular + .module('portainer.app.react.gitops', []) + .component( + 'pathSelector', + r2a(withUIRouter(withReactQuery(PathSelector)), [ + 'value', + 'onChange', + 'placeholder', + 'model', + 'dirOnly', + 'readOnly', + ]) + ) + .component( + 'relativePathFieldset', + r2a(withUIRouter(withReactQuery(RelativePathFieldset)), [ + 'value', + 'gitModel', + 'onChange', + 'readonly', + ]) + ); + +export const gitopsModule = ngModule.name; diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 0501929ca..49f75af11 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -69,3 +69,12 @@ export interface GitFormModel extends GitAuthModel { */ AutoUpdate?: AutoUpdateModel; } + +export interface RelativePathModel { + SupportRelativePath: boolean; + FilesystemPath?: string; + SupportPerDeviceConfigs?: boolean; + PerDeviceConfigsPath?: string; + PerDeviceConfigsMatchType?: string; + PerDeviceConfigsGroupMatchType?: string; +}