mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
refactor(edge/stacks): migrate edit view to react [EE-2222] (#11648)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
This commit is contained in:
parent
27e309754e
commit
cd5f342da0
31 changed files with 847 additions and 499 deletions
|
@ -10,9 +10,13 @@ import { FormValues } from './types';
|
|||
export function ComposeForm({
|
||||
handleContentChange,
|
||||
hasKubeEndpoint,
|
||||
handleVersionChange,
|
||||
versionOptions,
|
||||
}: {
|
||||
hasKubeEndpoint: boolean;
|
||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||
handleVersionChange: (newVersion: number) => void;
|
||||
versionOptions: number[] | undefined;
|
||||
}) {
|
||||
const { errors, values } = useFormikContext<FormValues>();
|
||||
|
||||
|
@ -62,6 +66,8 @@ export function ComposeForm({
|
|||
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
||||
error={errors.content}
|
||||
readonly={hasKubeEndpoint}
|
||||
versions={versionOptions}
|
||||
onVersionChange={handleVersionChange}
|
||||
>
|
||||
<div>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
|
|
|
@ -1,271 +1,12 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { array, boolean, number, object, SchemaOf, string } from 'yup';
|
||||
import { EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import {
|
||||
EnvironmentVariablesPanel,
|
||||
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';
|
||||
import { KubernetesForm } from './KubernetesForm';
|
||||
import { GitForm } from './GitForm';
|
||||
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
||||
import { NonGitStackForm } from './NonGitStackForm';
|
||||
|
||||
interface Props {
|
||||
edgeStack: EdgeStack;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
onEditorChange: (content: string) => void;
|
||||
fileContent: string;
|
||||
allowKubeToSelectCompose: boolean;
|
||||
}
|
||||
|
||||
const forms = {
|
||||
[DeploymentType.Compose]: ComposeForm,
|
||||
[DeploymentType.Kubernetes]: KubernetesForm,
|
||||
};
|
||||
|
||||
export function EditEdgeStackForm({
|
||||
isSubmitting,
|
||||
edgeStack,
|
||||
onSubmit,
|
||||
onEditorChange,
|
||||
fileContent,
|
||||
allowKubeToSelectCompose,
|
||||
}: Props) {
|
||||
export function EditEdgeStackForm({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
if (edgeStack.GitConfig) {
|
||||
return <GitForm stack={edgeStack} />;
|
||||
}
|
||||
|
||||
const formValues: FormValues = {
|
||||
edgeGroups: edgeStack.EdgeGroups,
|
||||
deploymentType: edgeStack.DeploymentType,
|
||||
privateRegistryId: edgeStack.Registries?.[0],
|
||||
content: fileContent,
|
||||
useManifestNamespaces: edgeStack.UseManifestNamespaces,
|
||||
prePullImage: edgeStack.PrePullImage,
|
||||
retryDeploy: edgeStack.RetryDeploy,
|
||||
webhookEnabled: !!edgeStack.Webhook,
|
||||
envVars: edgeStack.EnvVars || [],
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formValues}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={formValidation()}
|
||||
>
|
||||
<InnerForm
|
||||
edgeStack={edgeStack}
|
||||
isSubmitting={isSubmitting}
|
||||
onEditorChange={onEditorChange}
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerForm({
|
||||
onEditorChange,
|
||||
edgeStack,
|
||||
isSubmitting,
|
||||
allowKubeToSelectCompose,
|
||||
}: {
|
||||
edgeStack: EdgeStack;
|
||||
isSubmitting: boolean;
|
||||
onEditorChange: (content: string) => void;
|
||||
allowKubeToSelectCompose: boolean;
|
||||
}) {
|
||||
const {
|
||||
values,
|
||||
setFieldValue,
|
||||
isValid,
|
||||
|
||||
errors,
|
||||
setFieldError,
|
||||
} = useFormikContext<FormValues>();
|
||||
const { getCachedContent, setContentCache } = useCachedContent();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
|
||||
const DeploymentForm = forms[values.deploymentType];
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<EdgeGroupsSelector
|
||||
value={values.edgeGroups}
|
||||
onChange={(value) => setFieldValue('edgeGroups', value)}
|
||||
error={errors.edgeGroups}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||
<FormError>
|
||||
Edge groups with kubernetes environments no longer support compose
|
||||
deployment types in Portainer. Please select edge groups that only
|
||||
have docker environments when using compose deployment types.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
<EdgeStackDeploymentTypeSelector
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
value={values.deploymentType}
|
||||
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('content', getCachedContent(value));
|
||||
setFieldValue('deploymentType', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeploymentForm
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
handleContentChange={handleContentChange}
|
||||
/>
|
||||
|
||||
{isBE && (
|
||||
<>
|
||||
<FormSection title="Webhooks">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Create an Edge stack webhook"
|
||||
data-cy="edge-stack-enable-webhook-switch"
|
||||
checked={values.webhookEnabled}
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('webhookEnabled', value)}
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{edgeStack.Webhook && (
|
||||
<WebhookSettings
|
||||
baseUrl={baseEdgeStackWebhookUrl()}
|
||||
value={edgeStack.Webhook}
|
||||
docsLink="todo"
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
<PrivateRegistryFieldsetWrapper
|
||||
value={values.privateRegistryId}
|
||||
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||
values={{
|
||||
fileContent: values.content,
|
||||
}}
|
||||
onFieldError={(error) => setFieldError('privateRegistryId', error)}
|
||||
error={errors.privateRegistryId}
|
||||
/>
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<>
|
||||
<EnvironmentVariablesPanel
|
||||
onChange={(value) => setFieldValue('envVars', value)}
|
||||
values={values.envVars}
|
||||
errors={errors.envVars}
|
||||
/>
|
||||
|
||||
<PrePullToggle
|
||||
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||
value={values.prePullImage}
|
||||
/>
|
||||
|
||||
<RetryDeployToggle
|
||||
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||
value={values.retryDeploy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
data-cy="update-stack-button"
|
||||
size="small"
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
loadingText="Update in progress..."
|
||||
>
|
||||
Update the stack
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
|
||||
function handleContentChange(type: DeploymentType, content: string) {
|
||||
setFieldValue('content', content);
|
||||
setContentCache(type, content);
|
||||
onEditorChange(content);
|
||||
}
|
||||
}
|
||||
|
||||
function useCachedContent() {
|
||||
const [cachedContent, setCachedContent] = useState({
|
||||
[DeploymentType.Compose]: '',
|
||||
[DeploymentType.Kubernetes]: '',
|
||||
});
|
||||
|
||||
function handleChangeContent(type: DeploymentType, content: string) {
|
||||
setCachedContent((cache) => ({ ...cache, [type]: content }));
|
||||
}
|
||||
|
||||
return {
|
||||
setContentCache: handleChangeContent,
|
||||
getCachedContent: (type: DeploymentType) => cachedContent[type],
|
||||
};
|
||||
}
|
||||
|
||||
function formValidation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
content: string().required('Content is required'),
|
||||
deploymentType: number()
|
||||
.oneOf([0, 1, 2])
|
||||
.required('Deployment type is required'),
|
||||
privateRegistryId: number().optional(),
|
||||
prePullImage: boolean().default(false),
|
||||
retryDeploy: boolean().default(false),
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
edgeGroups: array()
|
||||
.of(number().required())
|
||||
.required()
|
||||
.min(1, 'At least one edge group is required'),
|
||||
webhookEnabled: boolean().default(false),
|
||||
envVars: envVarValidation(),
|
||||
});
|
||||
return <NonGitStackForm edgeStack={edgeStack} />;
|
||||
}
|
||||
|
|
|
@ -9,8 +9,12 @@ import { FormValues } from './types';
|
|||
|
||||
export function KubernetesForm({
|
||||
handleContentChange,
|
||||
handleVersionChange,
|
||||
versionOptions,
|
||||
}: {
|
||||
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||
handleVersionChange: (version: number) => void;
|
||||
versionOptions: number[] | undefined;
|
||||
}) {
|
||||
const { errors, values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
|
@ -38,6 +42,8 @@ export function KubernetesForm({
|
|||
handleContentChange(DeploymentType.Kubernetes, value)
|
||||
}
|
||||
error={errors.content}
|
||||
versions={versionOptions}
|
||||
onVersionChange={handleVersionChange}
|
||||
>
|
||||
<p>
|
||||
You can get more information about Kubernetes file format in the{' '}
|
||||
|
|
|
@ -0,0 +1,430 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { array, boolean, number, object, SchemaOf, string } from 'yup';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||
import {
|
||||
DeploymentType,
|
||||
EdgeStack,
|
||||
StaggerOption,
|
||||
} from '@/react/edge/edge-stacks/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings';
|
||||
import {
|
||||
baseEdgeStackWebhookUrl,
|
||||
createWebhookId,
|
||||
} from '@/portainer/helpers/webhookHelper';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import {
|
||||
EnvironmentVariablesPanel,
|
||||
envVarValidation,
|
||||
} from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { usePreventExit } from '@@/WebEditorForm';
|
||||
|
||||
import {
|
||||
getEdgeStackFile,
|
||||
useEdgeStackFile,
|
||||
} from '../../queries/useEdgeStackFile';
|
||||
import {
|
||||
StaggerFieldset,
|
||||
staggerConfigValidation,
|
||||
} from '../../components/StaggerFieldset';
|
||||
import { RetryDeployToggle } from '../../components/RetryDeployToggle';
|
||||
import { PrePullToggle } from '../../components/PrePullToggle';
|
||||
import { getDefaultStaggerConfig } from '../../components/StaggerFieldset.types';
|
||||
|
||||
import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper';
|
||||
import { FormValues } from './types';
|
||||
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
||||
import { useStaggerUpdateStatus } from './useStaggerUpdateStatus';
|
||||
import { useUpdateEdgeStackMutation } from './useUpdateEdgeStackMutation';
|
||||
import { ComposeForm } from './ComposeForm';
|
||||
import { KubernetesForm } from './KubernetesForm';
|
||||
import { useAllowKubeToSelectCompose } from './useAllowKubeToSelectCompose';
|
||||
|
||||
const forms = {
|
||||
[DeploymentType.Compose]: ComposeForm,
|
||||
[DeploymentType.Kubernetes]: KubernetesForm,
|
||||
};
|
||||
|
||||
export function NonGitStackForm({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
const mutation = useUpdateEdgeStackMutation();
|
||||
const fileQuery = useEdgeStackFile(edgeStack.Id, { skipErrors: true });
|
||||
const allowKubeToSelectCompose = useAllowKubeToSelectCompose(edgeStack);
|
||||
const router = useRouter();
|
||||
|
||||
if (!fileQuery.isSuccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = fileQuery.data || '';
|
||||
|
||||
const formValues: FormValues = {
|
||||
edgeGroups: edgeStack.EdgeGroups,
|
||||
deploymentType: edgeStack.DeploymentType,
|
||||
privateRegistryId: edgeStack.Registries?.[0],
|
||||
content: fileContent,
|
||||
useManifestNamespaces: edgeStack.UseManifestNamespaces,
|
||||
prePullImage: edgeStack.PrePullImage,
|
||||
retryDeploy: edgeStack.RetryDeploy,
|
||||
webhookEnabled: !!edgeStack.Webhook,
|
||||
envVars: edgeStack.EnvVars || [],
|
||||
rollbackTo: undefined,
|
||||
staggerConfig: edgeStack.StaggerConfig || getDefaultStaggerConfig(),
|
||||
};
|
||||
|
||||
const versionOptions = getVersions(edgeStack);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={formValidation()}
|
||||
>
|
||||
<InnerForm
|
||||
edgeStack={edgeStack}
|
||||
isLoading={mutation.isLoading}
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
versionOptions={versionOptions}
|
||||
isSaved={mutation.isSuccess}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
let rePullImage = false;
|
||||
if (isBE && values.deploymentType === DeploymentType.Compose) {
|
||||
const defaultToggle = values.prePullImage;
|
||||
const result = await confirmStackUpdate(
|
||||
'Do you want to force an update of the stack?',
|
||||
defaultToggle
|
||||
);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
rePullImage = result.pullImage;
|
||||
}
|
||||
|
||||
const updateVersion = !!(
|
||||
fileContent !== values.content ||
|
||||
values.privateRegistryId !== edgeStack.Registries[0] ||
|
||||
values.useManifestNamespaces !== edgeStack.UseManifestNamespaces ||
|
||||
values.prePullImage !== edgeStack.PrePullImage ||
|
||||
values.retryDeploy !== edgeStack.RetryDeploy ||
|
||||
!edgeStack.EnvVars ||
|
||||
_.differenceWith(values.envVars, edgeStack.EnvVars, _.isEqual).length >
|
||||
0 ||
|
||||
rePullImage
|
||||
);
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
id: edgeStack.Id,
|
||||
stackFileContent: values.content,
|
||||
edgeGroups: values.edgeGroups,
|
||||
deploymentType: values.deploymentType,
|
||||
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
|
||||
useManifestNamespaces: values.useManifestNamespaces,
|
||||
prePullImage: values.prePullImage,
|
||||
rePullImage,
|
||||
retryDeploy: values.retryDeploy,
|
||||
updateVersion,
|
||||
webhook: values.webhookEnabled
|
||||
? edgeStack.Webhook || createWebhookId()
|
||||
: undefined,
|
||||
envVars: values.envVars,
|
||||
rollbackTo: values.rollbackTo,
|
||||
staggerConfig: values.staggerConfig,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Stack successfully deployed');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
function getVersions(edgeStack: EdgeStack): Array<number> | undefined {
|
||||
if (!isBE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return _.compact([
|
||||
edgeStack.StackFileVersion,
|
||||
edgeStack.PreviousDeploymentInfo?.FileVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
function InnerForm({
|
||||
edgeStack,
|
||||
isLoading,
|
||||
allowKubeToSelectCompose,
|
||||
versionOptions,
|
||||
isSaved,
|
||||
}: {
|
||||
edgeStack: EdgeStack;
|
||||
isLoading: boolean;
|
||||
allowKubeToSelectCompose: boolean;
|
||||
versionOptions: number[] | undefined;
|
||||
isSaved: boolean;
|
||||
}) {
|
||||
const {
|
||||
values,
|
||||
setFieldValue,
|
||||
isValid,
|
||||
errors,
|
||||
setFieldError,
|
||||
initialValues,
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
usePreventExit(initialValues.content, values.content, !isSaved);
|
||||
|
||||
const { getCachedContent, setContentCache } = useCachedContent();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
|
||||
const staggerUpdateStatus = useStaggerUpdateStatus(edgeStack.Id);
|
||||
const [selectedVersion, setSelectedVersion] = useState(versionOptions?.[0]);
|
||||
const selectedParallelOption =
|
||||
values.staggerConfig.StaggerOption === StaggerOption.Parallel;
|
||||
|
||||
useEffect(() => {
|
||||
if (versionOptions && selectedVersion !== versionOptions[0]) {
|
||||
setFieldValue('rollbackTo', selectedVersion);
|
||||
} else {
|
||||
setFieldValue('rollbackTo', undefined);
|
||||
}
|
||||
}, [selectedVersion, setFieldValue, versionOptions]);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
|
||||
if (isBE && !staggerUpdateStatus.isSuccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const staggerUpdating =
|
||||
staggerUpdateStatus.data === 'updating' && selectedParallelOption;
|
||||
|
||||
const DeploymentForm = forms[values.deploymentType];
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<EdgeGroupsSelector
|
||||
value={values.edgeGroups}
|
||||
onChange={(value) => setFieldValue('edgeGroups', value)}
|
||||
error={errors.edgeGroups}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||
<FormError>
|
||||
Edge groups with kubernetes environments no longer support compose
|
||||
deployment types in Portainer. Please select edge groups that only
|
||||
have docker environments when using compose deployment types.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
<EdgeStackDeploymentTypeSelector
|
||||
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||
value={values.deploymentType}
|
||||
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
onChange={(value) => {
|
||||
setFieldValue('content', getCachedContent(value));
|
||||
setFieldValue('deploymentType', value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeploymentForm
|
||||
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||
handleContentChange={handleContentChange}
|
||||
versionOptions={versionOptions}
|
||||
handleVersionChange={handleVersionChange}
|
||||
/>
|
||||
|
||||
{isBE && (
|
||||
<>
|
||||
<FormSection title="Webhooks">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Create an Edge stack webhook"
|
||||
data-cy="edge-stack-enable-webhook-switch"
|
||||
checked={values.webhookEnabled}
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
onChange={(value) => setFieldValue('webhookEnabled', value)}
|
||||
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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{edgeStack.Webhook && (
|
||||
<>
|
||||
<WebhookSettings
|
||||
baseUrl={baseEdgeStackWebhookUrl()}
|
||||
value={edgeStack.Webhook}
|
||||
docsLink=""
|
||||
/>
|
||||
|
||||
<TextTip color="orange">
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<PrivateRegistryFieldsetWrapper
|
||||
value={values.privateRegistryId}
|
||||
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||
values={{
|
||||
fileContent: values.content,
|
||||
}}
|
||||
onFieldError={(error) => setFieldError('privateRegistryId', error)}
|
||||
error={errors.privateRegistryId}
|
||||
/>
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
<>
|
||||
<EnvironmentVariablesPanel
|
||||
onChange={(value) => setFieldValue('envVars', value)}
|
||||
values={values.envVars}
|
||||
errors={errors.envVars}
|
||||
/>
|
||||
|
||||
<PrePullToggle
|
||||
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||
value={values.prePullImage}
|
||||
/>
|
||||
|
||||
<RetryDeployToggle
|
||||
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||
value={values.retryDeploy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StaggerFieldset
|
||||
values={values.staggerConfig}
|
||||
onChange={(value) =>
|
||||
Object.entries(value).forEach(([key, value]) =>
|
||||
setFieldValue(`staggerConfig.${key}`, value)
|
||||
)
|
||||
}
|
||||
errors={errors.staggerConfig}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormSection title="Actions">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
data-cy="update-stack-button"
|
||||
size="small"
|
||||
disabled={!isValid || staggerUpdating}
|
||||
isLoading={isLoading}
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
loadingText="Update in progress..."
|
||||
>
|
||||
Update the stack
|
||||
</LoadingButton>
|
||||
</div>
|
||||
{staggerUpdating && (
|
||||
<div className="col-sm-12">
|
||||
<FormError>
|
||||
Concurrent updates in progress, stack update temporarily
|
||||
unavailable
|
||||
</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
|
||||
function handleContentChange(type: DeploymentType, content: string) {
|
||||
setFieldValue('content', content);
|
||||
setContentCache(type, content);
|
||||
}
|
||||
|
||||
async function handleVersionChange(newVersion: number) {
|
||||
if (!versionOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = await getEdgeStackFile(edgeStack.Id, newVersion).catch(
|
||||
() => ''
|
||||
);
|
||||
if (fileContent) {
|
||||
if (versionOptions.length > 1) {
|
||||
if (newVersion < versionOptions[0]) {
|
||||
setSelectedVersion(newVersion);
|
||||
} else {
|
||||
setSelectedVersion(versionOptions[0]);
|
||||
}
|
||||
}
|
||||
handleContentChange(values.deploymentType, fileContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useCachedContent() {
|
||||
const [cachedContent, setCachedContent] = useState({
|
||||
[DeploymentType.Compose]: '',
|
||||
[DeploymentType.Kubernetes]: '',
|
||||
});
|
||||
|
||||
function handleChangeContent(type: DeploymentType, content: string) {
|
||||
setCachedContent((cache) => ({ ...cache, [type]: content }));
|
||||
}
|
||||
|
||||
return {
|
||||
setContentCache: handleChangeContent,
|
||||
getCachedContent: (type: DeploymentType) => cachedContent[type],
|
||||
};
|
||||
}
|
||||
|
||||
function formValidation(): SchemaOf<FormValues> {
|
||||
return object({
|
||||
content: string().required('Content is required'),
|
||||
deploymentType: number()
|
||||
.oneOf([0, 1, 2])
|
||||
.required('Deployment type is required'),
|
||||
privateRegistryId: number().optional(),
|
||||
prePullImage: boolean().default(false),
|
||||
retryDeploy: boolean().default(false),
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
edgeGroups: array()
|
||||
.of(number().required())
|
||||
.required()
|
||||
.min(1, 'At least one edge group is required'),
|
||||
webhookEnabled: boolean().default(false),
|
||||
versions: array().of(number().optional()).optional(),
|
||||
envVars: envVarValidation(),
|
||||
rollbackTo: number().optional(),
|
||||
staggerConfig: staggerConfigValidation(),
|
||||
});
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { DeploymentType } from '@/react/edge/edge-stacks/types';
|
||||
import { DeploymentType, StaggerConfig } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
||||
|
||||
|
@ -13,4 +13,6 @@ export interface FormValues {
|
|||
retryDeploy: boolean;
|
||||
webhookEnabled: boolean;
|
||||
envVars: EnvVar[];
|
||||
rollbackTo?: number;
|
||||
staggerConfig: StaggerConfig;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { DeploymentType, EdgeStack } from '../../types';
|
||||
|
||||
export function useAllowKubeToSelectCompose(edgeStack: EdgeStack) {
|
||||
const edgeGroupsQuery = useEdgeGroups();
|
||||
|
||||
const initiallyContainsKubeEnv = _.compact(
|
||||
edgeStack.EdgeGroups.map(
|
||||
(id) => edgeGroupsQuery.data?.find((e) => e.Id === id)
|
||||
)
|
||||
)
|
||||
.flatMap((group) => group.EndpointTypes)
|
||||
.includes(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
|
||||
return (
|
||||
initiallyContainsKubeEnv &&
|
||||
edgeStack.DeploymentType === DeploymentType.Compose
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { EdgeStack } from '../../types';
|
||||
import { queryKeys } from '../../queries/query-keys';
|
||||
import { buildUrl } from '../../queries/buildUrl';
|
||||
|
||||
export function staggerStatusQueryKey(edgeStackId: EdgeStack['Id']) {
|
||||
return [...queryKeys.item(edgeStackId), 'stagger', 'status'] as const;
|
||||
}
|
||||
|
||||
export function useStaggerUpdateStatus(edgeStackId: EdgeStack['Id']) {
|
||||
return useQuery(
|
||||
[...queryKeys.item(edgeStackId), 'stagger-status'],
|
||||
() => getStaggerStatus(edgeStackId),
|
||||
{ enabled: isBE }
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerStatusResponse {
|
||||
status: 'idle' | 'updating';
|
||||
}
|
||||
|
||||
async function getStaggerStatus(edgeStackId: EdgeStack['Id']) {
|
||||
try {
|
||||
const { data } = await axios.get<StaggerStatusResponse>(
|
||||
buildUrl(edgeStackId, 'stagger/status')
|
||||
);
|
||||
return data.status;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error, 'Unable to retrieve stagger status');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import {
|
||||
mutationOptions,
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
|
||||
import {
|
||||
DeploymentType,
|
||||
EdgeStack,
|
||||
StaggerConfig,
|
||||
} from '@/react/edge/edge-stacks/types';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { Pair } from '@/react/portainer/settings/types';
|
||||
|
||||
import { queryKeys } from '../../queries/query-keys';
|
||||
|
||||
export interface UpdateEdgeStackPayload {
|
||||
id: EdgeStack['Id'];
|
||||
stackFileContent: string;
|
||||
edgeGroups: Array<EdgeGroup['Id']>;
|
||||
deploymentType: DeploymentType;
|
||||
registries: Array<Registry['Id']>;
|
||||
useManifestNamespaces: boolean;
|
||||
prePullImage?: boolean;
|
||||
rePullImage?: boolean;
|
||||
retryDeploy?: boolean;
|
||||
updateVersion: boolean;
|
||||
webhook?: string;
|
||||
envVars: Pair[];
|
||||
rollbackTo?: number;
|
||||
staggerConfig?: StaggerConfig;
|
||||
}
|
||||
|
||||
export function useUpdateEdgeStackMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
updateEdgeStack,
|
||||
mutationOptions(
|
||||
withError('Failed updating stack'),
|
||||
withInvalidate(queryClient, [queryKeys.base()])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function updateEdgeStack({ id, ...payload }: UpdateEdgeStackPayload) {
|
||||
try {
|
||||
await axios.put(buildUrl(id), payload);
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Failed updating stack');
|
||||
}
|
||||
}
|
|
@ -140,6 +140,7 @@ function getEnvStackStatus(
|
|||
status = {
|
||||
EndpointID: envId,
|
||||
DeploymentInfo: {
|
||||
Version: 0,
|
||||
ConfigHash: '',
|
||||
FileVersion: 0,
|
||||
},
|
||||
|
|
74
app/react/edge/edge-stacks/ItemView/ItemView.tsx
Normal file
74
app/react/edge/edge-stacks/ItemView/ItemView.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { HardDriveIcon, LayersIcon } from 'lucide-react';
|
||||
|
||||
import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { useEdgeStack } from '../queries/useEdgeStack';
|
||||
|
||||
import { EnvironmentsDatatable } from './EnvironmentsDatatable';
|
||||
|
||||
export function ItemView() {
|
||||
const idParam = useIdParam('stackId');
|
||||
const edgeStackQuery = useEdgeStack(idParam);
|
||||
|
||||
const [tab = 'stack', setTab] = useParamState<'stack' | 'environments'>(
|
||||
'tab'
|
||||
);
|
||||
|
||||
if (!edgeStackQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = edgeStackQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edit Edge stack"
|
||||
breadcrumbs={[
|
||||
{ label: 'Edge Stacks', link: 'edge.stacks' },
|
||||
stack.Name,
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body className="!p-0">
|
||||
<NavTabs<'stack' | 'environments'>
|
||||
justified
|
||||
type="pills"
|
||||
options={[
|
||||
{
|
||||
id: 'stack',
|
||||
label: 'Stack',
|
||||
icon: LayersIcon,
|
||||
children: (
|
||||
<div className="p-5 pb-10">
|
||||
<EditEdgeStackForm edgeStack={stack} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'environments',
|
||||
icon: HardDriveIcon,
|
||||
label: 'Environments',
|
||||
children: <EnvironmentsDatatable />,
|
||||
},
|
||||
]}
|
||||
selectedId={tab}
|
||||
onSelect={setTab}
|
||||
/>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -3,4 +3,6 @@ import { EdgeStack } from '../types';
|
|||
export const queryKeys = {
|
||||
base: () => ['edge-stacks'] as const,
|
||||
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
||||
file: (id: EdgeStack['Id'], version?: number) =>
|
||||
[...queryKeys.item(id), 'file', { version }] as const,
|
||||
};
|
||||
|
|
44
app/react/edge/edge-stacks/queries/useEdgeStackFile.ts
Normal file
44
app/react/edge/edge-stacks/queries/useEdgeStackFile.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeStack } from '../types';
|
||||
|
||||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStackFile(
|
||||
id: EdgeStack['Id'],
|
||||
{ skipErrors, version }: { version?: number; skipErrors?: boolean } = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.file(id, version),
|
||||
queryFn: () =>
|
||||
getEdgeStackFile(id, version).catch((e) => {
|
||||
if (!skipErrors) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return '';
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
interface StackFileResponse {
|
||||
StackFileContent: string;
|
||||
}
|
||||
|
||||
export async function getEdgeStackFile(id?: EdgeStack['Id'], version?: number) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get<StackFileResponse>(buildUrl(id, 'file'), {
|
||||
params: { version },
|
||||
});
|
||||
return data.StackFileContent;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
|
@ -10,6 +10,8 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
|||
|
||||
import { EdgeGroup } from '../edge-groups/types';
|
||||
|
||||
import { type StaggerConfig } from './components/StaggerFieldset.types';
|
||||
|
||||
export {
|
||||
type StaggerConfig,
|
||||
StaggerOption,
|
||||
|
@ -55,6 +57,7 @@ export interface DeploymentStatus {
|
|||
}
|
||||
|
||||
interface EdgeStackDeploymentInfo {
|
||||
Version: number;
|
||||
FileVersion: number;
|
||||
ConfigHash: string;
|
||||
}
|
||||
|
@ -94,9 +97,11 @@ export type EdgeStack = RelativePathModel & {
|
|||
GitConfig?: RepoConfigResponse;
|
||||
Prune: boolean;
|
||||
RetryDeploy: boolean;
|
||||
Webhook?: string;
|
||||
Webhook: string;
|
||||
StackFileVersion?: number;
|
||||
PreviousDeploymentInfo: EdgeStackDeploymentInfo;
|
||||
EnvVars?: EnvVar[];
|
||||
StaggerConfig?: StaggerConfig;
|
||||
SupportRelativePath: boolean;
|
||||
FilesystemPath?: string;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue