diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts index 9a886639c..af0c7d460 100644 --- a/app/portainer/react/components/git-form.ts +++ b/app/portainer/react/components/git-form.ts @@ -10,7 +10,6 @@ import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset'; import { InfoPanel } from '@/react/portainer/gitops/InfoPanel'; import { RefField } from '@/react/portainer/gitops/RefField'; import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay'; -import { StackRedeployGitForm } from '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm'; export const gitFormModule = angular .module('portainer.app.components.forms.git', []) @@ -80,12 +79,4 @@ export const gitFormModule = angular .component( 'timeWindowDisplay', r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), []) - ) - .component( - 'stackRedeployGitForm', - r2a(withUIRouter(withReactQuery(withCurrentUser(StackRedeployGitForm))), [ - 'model', - 'stack', - 'endpoint', - ]) ).name; diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts index d775f479c..b95c7d512 100644 --- a/app/react/common/stacks/types.ts +++ b/app/react/common/stacks/types.ts @@ -5,6 +5,8 @@ import { RepoConfigResponse, } from '@/react/portainer/gitops/types'; +import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; + export type StackId = number; export enum StackType { @@ -34,10 +36,7 @@ export interface Stack { EndpointId: number; SwarmId: string; EntryPoint: string; - Env: { - name: string; - value: string; - }[]; + Env: EnvVar[]; ResourceControl?: ResourceControlResponse; Status: StackStatus; ProjectPath: string; @@ -67,7 +66,7 @@ export type StackFile = { }; export interface GitStackPayload { - env: Array<{ name: string; value: string }>; + env: Array; prune?: boolean; RepositoryReferenceName?: string; RepositoryAuthentication?: boolean; diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx index cf313ec36..af1011fd5 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx @@ -9,6 +9,10 @@ import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; import { StackInfoTab } from './StackInfoTab'; +vi.mock('@/react/hooks/useEnvironmentId', () => ({ + useEnvironmentId: vi.fn(() => 1), +})); + // Mock the child components to isolate testing vi.mock('./AssociateStackForm', () => ({ AssociateStackForm: vi.fn(() => ( @@ -29,14 +33,11 @@ vi.mock( }) ); -vi.mock( - '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm', - () => ({ - StackRedeployGitForm: vi.fn(() => ( -
StackRedeployGitForm
- )), - }) -); +vi.mock('./StackRedeployGitForm/StackRedeployGitForm', () => ({ + StackRedeployGitForm: vi.fn(() => ( +
StackRedeployGitForm
+ )), +})); describe('StackInfoTab', () => { describe('initial rendering', () => { diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx index 9eaa2fc89..94f9061f0 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx @@ -1,15 +1,13 @@ import { AlertTriangle } from 'lucide-react'; -import { EnvironmentId } from '@/react/portainer/environments/types'; import { Stack } from '@/react/common/stacks/types'; import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm'; -import { StackRedeployGitForm } from '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm'; -import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; import { Authorized } from '@/react/hooks/useUser'; import { Icon } from '@@/Icon'; import { FormSection } from '@@/form-components/FormSection'; +import { StackRedeployGitForm } from './StackRedeployGitForm/StackRedeployGitForm'; import { StackActions } from './StackActions'; import { AssociateStackForm } from './AssociateStackForm'; @@ -75,10 +73,7 @@ export function StackInfoTab({ <> {stack.GitConfig && !stack.FromAppTemplate && ( - + )} @@ -132,28 +127,3 @@ function ExternalOrphanedWarning({ ); } - -function StackRedeployGitFormWrapper({ - stack, - environmentId, -}: { - stack: Stack; - environmentId: EnvironmentId; -}) { - const apiVersion = useApiVersion(environmentId); - - if (!stack.GitConfig) { - return null; - } - - return ( - - ); -} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/ActionsSection.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/ActionsSection.tsx new file mode 100644 index 000000000..0df4e5a7e --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/ActionsSection.tsx @@ -0,0 +1,50 @@ +import { RefreshCw } from 'lucide-react'; + +import { FormSection } from '@@/form-components/FormSection'; +import { LoadingButton } from '@@/buttons'; + +interface Props { + isDirty: boolean; + isValid: boolean; + isSaveLoading: boolean; + isDeployLoading: boolean; + onDeploy: () => void; +} + +export function ActionsSection({ + isDirty, + isValid, + isSaveLoading, + isDeployLoading, + onDeploy, +}: Props) { + return ( + + + + Pull and redeploy + + + + Save settings + + + ); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/AdvancedConfigurationSection.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/AdvancedConfigurationSection.tsx new file mode 100644 index 000000000..a3187f621 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/AdvancedConfigurationSection.tsx @@ -0,0 +1,112 @@ +import { MinusIcon, PlusIcon } from 'lucide-react'; +import { useReducer } from 'react'; +import { useFormikContext } from 'formik'; + +import { Stack } from '@/react/common/stacks/types'; +import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset'; +import { RefField } from '@/react/portainer/gitops/RefField'; +import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset'; +import { RelativePathModel } from '@/react/portainer/gitops/types'; +import { RefFieldModel } from '@/react/portainer/gitops/RefField/types'; + +import { Icon } from '@@/Icon'; +import { Button } from '@@/buttons'; + +import { FormValues } from './types'; +import { TLSVerificationField } from './TLSVerificationField'; + +interface Props { + stack: Stack; +} + +export function AdvancedConfigurationSection({ stack }: Props) { + const { values, setFieldValue, errors, initialValues } = + useFormikContext(); + const [isAdvancedMode, toggleAdvancedMode] = useReducer( + (state) => !state, + false + ); + + const gitConfig = stack.GitConfig; + + if (!gitConfig) { + return null; + } + + const valuesToPassDownToFields: RefFieldModel = { + RepositoryURL: gitConfig.URL || '', + RepositoryAuthentication: values.auth.RepositoryAuthentication, + RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType, + RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID, + RepositoryUsername: values.auth.RepositoryUsername, + RepositoryPassword: values.auth.RepositoryPassword, + TLSSkipVerify: values.tlsSkipVerify, + }; + + const relativePathValues: RelativePathModel = { + FilesystemPath: stack.FilesystemPath, + SupportRelativePath: stack.SupportRelativePath, + PerDeviceConfigsGroupMatchType: '', + SupportPerDeviceConfigs: false, + PerDeviceConfigsMatchType: '', + PerDeviceConfigsPath: '', + }; + + return ( + <> +
+
+ +
+
+ + {isAdvancedMode && ( + <> + setFieldValue('refName', value)} + model={valuesToPassDownToFields} + isUrlValid + stackId={stack.Id} + error={errors.refName} + /> + + { + Object.entries(value).forEach(([key, val]) => { + setFieldValue(`auth.${key}`, val); + }); + }} + isAuthExplanationVisible + errors={errors.auth} + /> + + setFieldValue('tlsSkipVerify', value)} + /> + + {}} + /> + + )} + + ); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/InnerForm.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/InnerForm.tsx new file mode 100644 index 000000000..8d27213f2 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/InnerForm.tsx @@ -0,0 +1,89 @@ +import { Form, useFormikContext } from 'formik'; + +import { Stack, StackType } from '@/react/common/stacks/types'; +import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; +import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset'; +import { InfoPanel } from '@/react/portainer/gitops/InfoPanel'; +import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay'; + +import { FormSection } from '@@/form-components/FormSection'; +import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset'; + +import { FormValues } from './types'; +import { AdvancedConfigurationSection } from './AdvancedConfigurationSection'; +import { OptionsSection } from './OptionsSection'; +import { ActionsSection } from './ActionsSection'; + +export function InnerForm({ + stack, + onDeploy, + webhookId, + isDeployLoading, + isSaveLoading, +}: { + stack: Stack; + webhookId: string; + onDeploy(values: FormValues): Promise; + isSaveLoading: boolean; + isDeployLoading: boolean; +}) { + const envId = useEnvironmentId(); + const apiVersion = useApiVersion(envId); + const { values, setFieldValue, errors, dirty, isValid } = + useFormikContext(); + + const gitConfig = stack.GitConfig; + + if (!gitConfig) { + return null; + } + + return ( +
+ + + + setFieldValue('autoUpdate', value)} + environmentType="DOCKER" + isForcePullVisible={stack.Type !== StackType.Kubernetes} + baseWebhookUrl={baseStackWebhookUrl()} + webhookId={webhookId} + webhooksDocs="/user/docker/stacks/webhooks" + errors={errors.autoUpdate} + /> + + + + + + setFieldValue('env', value)} + showHelpMessage + isFoldable + errors={errors.env} + /> + + + + onDeploy(values)} + /> + +
+ ); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/OptionsSection.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/OptionsSection.tsx new file mode 100644 index 000000000..158e850ac --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/OptionsSection.tsx @@ -0,0 +1,39 @@ +import { useFormikContext } from 'formik'; + +import { Stack, StackType } from '@/react/common/stacks/types'; + +import { SwitchField } from '@@/form-components/SwitchField'; +import { FormSection } from '@@/form-components/FormSection'; + +import { FormValues } from './types'; + +interface Props { + stack: Stack; + apiVersion: number; +} + +export function OptionsSection({ stack, apiVersion }: Props) { + const { values, setFieldValue } = useFormikContext(); + + if (stack.Type !== StackType.DockerSwarm || apiVersion < 1.27) { + return null; + } + + return ( + +
+
+ setFieldValue('prune', value)} + data-cy="stack-prune-services-switch" + /> +
+
+
+ ); +} diff --git a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx similarity index 75% rename from app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.test.tsx rename to app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx index 6cba2ef5b..839499fdf 100644 --- a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.test.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.test.tsx @@ -1,11 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { vi } from 'vitest'; +import { HttpResponse } from 'msw'; +import _ from 'lodash'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestRouter } from '@/react/test-utils/withRouter'; -import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack'; -import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings'; import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils'; import { @@ -13,10 +13,13 @@ import { createWebhookId, } from '@/portainer/helpers/webhookHelper'; import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { Stack } from '@/react/common/stacks/types'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { useApiVersion } from '@/react/docker/proxy/queries/useVersion'; +import { http, server } from '@/setup-tests/server'; import { StackRedeployGitForm } from './StackRedeployGitForm'; -// Extract the props type from the component type StackRedeployGitFormProps = React.ComponentProps< typeof StackRedeployGitForm >; @@ -31,14 +34,6 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ })), })); -vi.mock('@/react/portainer/gitops/queries/useUpdateGitStack', () => ({ - useUpdateGitStack: vi.fn(), -})); - -vi.mock('@/react/portainer/gitops/queries/useUpdateGitStackSettings', () => ({ - useUpdateGitStackSettings: vi.fn(), -})); - vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({ confirmStackUpdate: vi.fn(), })); @@ -116,7 +111,8 @@ vi.mock('@/react/portainer/gitops/RefField', () => ({ RefField: vi.fn(() =>
Ref Field
), })); -vi.mock('@/react/portainer/gitops/AuthFieldset', () => ({ +vi.mock('@/react/portainer/gitops/AuthFieldset', async (importOriginal) => ({ + ...(await importOriginal()), AuthFieldset: vi.fn(() => (
Repository Authentication
@@ -133,91 +129,62 @@ vi.mock( }) ); +vi.mock('@@/form-components/MultiRegistrySelectFieldset', () => ({ + MultiRegistrySelectFieldset: vi.fn( + ({ + options, + }: { + options: Array<{ Id: number; Name: string }>; + value: number[]; + }) => ( +
+ {options?.map((registry: { Id: number; Name: string }) => ( + {registry.Name} + ))} +
+ ) + ), +})); + vi.mock('@/portainer/services/notifications', () => ({ notifySuccess: vi.fn(), notifyError: vi.fn(), })); -const mockUseUpdateGitStack = vi.mocked(useUpdateGitStack); -const mockUseUpdateGitStackSettings = vi.mocked(useUpdateGitStackSettings); +vi.mock('@/react/docker/proxy/queries/useVersion', () => ({ + useApiVersion: vi.fn(), +})); + +// In test setup or beforeEach +beforeEach(() => { + vi.mocked(useApiVersion).mockReturnValue(1.27); +}); + const mockConfirmStackUpdate = vi.mocked(confirmStackUpdate); const mockConfirmEnableTLSVerify = vi.mocked(confirmEnableTLSVerify); const mockBaseStackWebhookUrl = vi.mocked(baseStackWebhookUrl); const mockCreateWebhookId = vi.mocked(createWebhookId); describe('StackRedeployGitForm', () => { - const defaultProps: StackRedeployGitFormProps = { - model: { - URL: 'https://github.com/test/repo', - ReferenceName: 'refs/heads/main', - ConfigFilePath: 'docker-compose.yml', - ConfigHash: 'abc123', - TLSSkipVerify: false, - }, - stack: { - Id: 1, - EndpointId: 1, - Type: 1, // Swarm stack - Env: [ - { name: 'ENV1', value: 'value1' }, - { name: 'ENV2', value: 'value2' }, - ], - Option: { - Prune: false, - }, - AdditionalFiles: ['file1.yml', 'file2.yml'], - AutoUpdate: { - Interval: '5m', - Webhook: 'test-webhook-id', - ForceUpdate: false, - ForcePullImage: false, - }, - }, - endpoint: { - apiVersion: 1.27, - Id: 1, - }, - }; - - const mockUpdateGitStackMutation = { - mutateAsync: vi.fn(), - isLoading: false, - error: null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - const mockUpdateGitStackSettingsMutation = { - mutateAsync: vi.fn(), - isLoading: false, - error: null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - beforeEach(() => { vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockUseUpdateGitStack.mockReturnValue(mockUpdateGitStackMutation as any); - mockUseUpdateGitStackSettings.mockReturnValue( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockUpdateGitStackSettingsMutation as any - ); mockConfirmStackUpdate.mockResolvedValue({ pullImage: false }); mockConfirmEnableTLSVerify.mockResolvedValue(true); mockBaseStackWebhookUrl.mockReturnValue( 'http://localhost:9000/api/webhooks' ); mockCreateWebhookId.mockReturnValue('test-webhook-id'); - }); - function renderComponent(props = {}) { - const Component = withTestQueryProvider( - withTestRouter(() => ( - - )) + server.use( + http.put('/api/stacks/:id/git/redeploy', () => + HttpResponse.json({ success: true }) + ), + http.post('/api/stacks/:id/git', () => + HttpResponse.json({ success: true }) + ) ); - return render(); - } + }); describe('Basic rendering', () => { it('should render the form with correct sections', () => { @@ -226,7 +193,8 @@ describe('StackRedeployGitForm', () => { expect( screen.getByText('Redeploy from git repository') ).toBeInTheDocument(); - expect(screen.getByText('Options')).toBeInTheDocument(); + + expect(screen.getByText('Options')).toBeInTheDocument(); // available only when apiVersion is >= 1.27 expect(screen.getByText('Actions')).toBeInTheDocument(); }); @@ -313,10 +281,14 @@ describe('StackRedeployGitForm', () => { it('should call confirmEnableTLSVerify when enabling TLS verification', async () => { const user = userEvent.setup(); - const propsWithTLSDisabled = { - ...defaultProps, - model: { ...defaultProps.model, TLSSkipVerify: true }, + const propsWithTLSDisabled: DeepPartial = { + stack: { + GitConfig: { + TLSSkipVerify: true, + }, + }, }; + renderComponent(propsWithTLSDisabled); const toggleButton = screen.getByTestId( @@ -335,6 +307,7 @@ describe('StackRedeployGitForm', () => { describe('Options section', () => { it('should show prune services option for swarm stacks with API version >= 1.27', () => { + vi.mocked(useApiVersion).mockReturnValue(1.27); renderComponent(); expect(screen.getByText('Prune services')).toBeInTheDocument(); @@ -344,21 +317,20 @@ describe('StackRedeployGitForm', () => { }); it('should not show options section for non-swarm stacks', () => { - const propsWithComposeStack = { - ...defaultProps, - stack: { ...defaultProps.stack, Type: 2 }, // Compose stack - }; - renderComponent(propsWithComposeStack); + vi.mocked(useApiVersion).mockReturnValue(1.27); + + renderComponent({ + stack: { + Type: 2, + }, + }); expect(screen.queryByText('Options')).not.toBeInTheDocument(); }); it('should not show options section for older API versions', () => { - const propsWithOldAPI = { - ...defaultProps, - endpoint: { ...defaultProps.endpoint, apiVersion: 1.26 }, - }; - renderComponent(propsWithOldAPI); + vi.mocked(useApiVersion).mockReturnValue(1.26); + renderComponent(); expect(screen.queryByText('Options')).not.toBeInTheDocument(); }); @@ -379,30 +351,38 @@ describe('StackRedeployGitForm', () => { }); it('should call updateGitStack mutation when confirmed', async () => { + let requestBody: unknown = null; + server.use( + http.put('/api/stacks/:id/git/redeploy', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + const user = userEvent.setup(); - mockUpdateGitStackMutation.mutateAsync.mockResolvedValue({}); renderComponent(); const redeployButton = screen.getByTestId('stack-redeploy-button'); await user.click(redeployButton); await waitFor(() => { - expect(mockUpdateGitStackMutation.mutateAsync).toHaveBeenCalledWith({ - env: defaultProps.stack.Env, - prune: false, - RepositoryReferenceName: 'refs/heads/main', - RepositoryAuthentication: false, - RepositoryGitCredentialID: 0, - RepositoryUsername: '', - RepositoryPassword: '', - PullImage: false, - }); + expect(requestBody).toEqual( + expect.objectContaining({ + prune: false, + RepositoryReferenceName: 'refs/heads/main', + }) + ); }); }); it('should notify success on successful redeploy', async () => { + server.use( + http.put('/api/stacks/:id/git/redeploy', async () => + HttpResponse.json({ success: true }) + ) + ); + const user = userEvent.setup(); - mockUpdateGitStackMutation.mutateAsync.mockResolvedValue({}); renderComponent(); @@ -416,10 +396,13 @@ describe('StackRedeployGitForm', () => { it('should disable redeploy button when in progress', async () => { const user = userEvent.setup(); - // Mock the mutation to simulate loading state - mockUpdateGitStackMutation.mutateAsync.mockImplementation( - () => new Promise(() => {}) - ); // Never resolves + server.use( + http.put('/api/stacks/:id/git/redeploy', async () => { + // never resolve + await new Promise(() => {}); + return HttpResponse.json({ success: true }); + }) + ); renderComponent(); const redeployButton = screen.getByTestId('stack-redeploy-button'); @@ -434,8 +417,16 @@ describe('StackRedeployGitForm', () => { describe('Save settings functionality', () => { it('should call updateGitStackSettings mutation when save button is clicked', async () => { + let requestBody: unknown; + server.use( + http.post('/api/stacks/:id/git', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + const user = userEvent.setup(); - mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({}); + renderComponent(); // Make a change to enable the save button @@ -453,18 +444,13 @@ describe('StackRedeployGitForm', () => { await user.click(saveButton); await waitFor(() => { - expect( - mockUpdateGitStackSettingsMutation.mutateAsync - ).toHaveBeenCalledWith({ - stackId: 1, - endpointId: 1, - payload: expect.objectContaining({ - env: defaultProps.stack.Env, + expect(requestBody).toEqual( + expect.objectContaining({ RepositoryReferenceName: 'refs/heads/main', prune: false, TLSSkipVerify: true, - }), - }); + }) + ); }); }); @@ -495,7 +481,13 @@ describe('StackRedeployGitForm', () => { }); it('should disable save button when in progress', () => { - mockUpdateGitStackSettingsMutation.isLoading = true; + server.use( + http.post('/api/stacks/:id/git', async () => { + // never resolve + await new Promise(() => {}); + return HttpResponse.json({ success: true }); + }) + ); renderComponent(); const saveButton = screen.getByTestId('stack-save-settings-button'); @@ -524,12 +516,18 @@ describe('StackRedeployGitForm', () => { await user.click(tlsSwitch); // Should now have unsaved changes - expect(saveButton).not.toBeDisabled(); + await waitFor(() => { + expect(saveButton).not.toBeDisabled(); + }); }); it('should clear unsaved changes after successful save', async () => { const user = userEvent.setup(); - mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({}); + server.use( + http.put('/api/stacks/:id/git/redeploy', async () => + HttpResponse.json({ success: true }) + ) + ); renderComponent(); // Make a change @@ -556,9 +554,12 @@ describe('StackRedeployGitForm', () => { describe('Error handling', () => { it('should handle updateGitStack mutation errors gracefully', async () => { const user = userEvent.setup(); - mockUpdateGitStackMutation.mutateAsync.mockRejectedValue( - new Error('Update failed') + server.use( + http.put('/api/stacks/:id/git/redeploy', async () => + HttpResponse.json({ error: 'Update failed' }, { status: 400 }) + ) ); + renderComponent(); const redeployButton = screen.getByTestId('stack-redeploy-button'); @@ -571,9 +572,12 @@ describe('StackRedeployGitForm', () => { it('should handle updateGitStackSettings mutation errors gracefully', async () => { const user = userEvent.setup(); - mockUpdateGitStackSettingsMutation.mutateAsync.mockRejectedValue( - new Error('Save failed') + server.use( + http.post('/api/stacks/:id/git', async () => + HttpResponse.json({ error: 'Update failed' }, { status: 400 }) + ) ); + renderComponent(); // Make a change to enable save button @@ -592,7 +596,7 @@ describe('StackRedeployGitForm', () => { // Should not clear unsaved changes on error await waitFor(() => { - expect(saveButton).not.toBeDisabled(); + expect(saveButton).toBeEnabled(); }); }); }); @@ -600,10 +604,8 @@ describe('StackRedeployGitForm', () => { describe('Git authentication', () => { it('should handle git authentication configuration', async () => { const user = userEvent.setup(); - const propsWithAuth = { - ...defaultProps, + const propsWithAuth: DeepPartial = { stack: { - ...defaultProps.stack, GitConfig: { Authentication: { Username: 'testuser', @@ -627,20 +629,23 @@ describe('StackRedeployGitForm', () => { describe('Webhook configuration', () => { it('should generate webhook ID when no webhook is provided and use it in save settings', async () => { + let requestBody: unknown; + server.use( + http.post('/api/stacks/:id/git', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); const user = userEvent.setup(); - const propsWithoutWebhook = { - ...defaultProps, + mockCreateWebhookId.mockReturnValue('generated-webhook-id'); + + renderComponent({ stack: { - ...defaultProps.stack, AutoUpdate: { - ...defaultProps.stack.AutoUpdate, Webhook: '', }, }, - }; - mockCreateWebhookId.mockReturnValue('generated-webhook-id'); - mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({}); - renderComponent(propsWithoutWebhook); + }); expect(mockCreateWebhookId).toHaveBeenCalled(); @@ -659,16 +664,10 @@ describe('StackRedeployGitForm', () => { await user.click(saveButton); await waitFor(() => { - expect( - mockUpdateGitStackSettingsMutation.mutateAsync - ).toHaveBeenCalledWith( + expect(requestBody).toEqual( expect.objectContaining({ - stackId: 1, - endpointId: 1, - payload: expect.objectContaining({ - AutoUpdate: expect.objectContaining({ - Webhook: 'generated-webhook-id', - }), + AutoUpdate: expect.objectContaining({ + Webhook: 'generated-webhook-id', }), }) ); @@ -677,18 +676,21 @@ describe('StackRedeployGitForm', () => { it('should use existing webhook ID from stack without generating new one', async () => { const user = userEvent.setup(); - const propsWithWebhook = { - ...defaultProps, + let requestBody: unknown; + server.use( + http.post('/api/stacks/:id/git', async ({ request }) => { + requestBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + + renderComponent({ stack: { - ...defaultProps.stack, AutoUpdate: { - ...defaultProps.stack.AutoUpdate, Webhook: 'existing-webhook-id', }, }, - }; - mockUpdateGitStackSettingsMutation.mutateAsync.mockResolvedValue({}); - renderComponent(propsWithWebhook); + }); expect(mockCreateWebhookId).not.toHaveBeenCalled(); @@ -707,16 +709,10 @@ describe('StackRedeployGitForm', () => { await user.click(saveButton); await waitFor(() => { - expect( - mockUpdateGitStackSettingsMutation.mutateAsync - ).toHaveBeenCalledWith( + expect(requestBody).toEqual( expect.objectContaining({ - stackId: 1, - endpointId: 1, - payload: expect.objectContaining({ - AutoUpdate: expect.objectContaining({ - Webhook: 'existing-webhook-id', - }), + AutoUpdate: expect.objectContaining({ + Webhook: 'existing-webhook-id', }), }) ); @@ -724,3 +720,47 @@ describe('StackRedeployGitForm', () => { }); }); }); + +const defaultProps: StackRedeployGitFormProps = { + stack: { + GitConfig: { + URL: 'https://github.com/test/repo', + ReferenceName: 'refs/heads/main', + ConfigFilePath: 'docker-compose.yml', + ConfigHash: 'abc123', + TLSSkipVerify: false, + }, + Name: 'stack', + + Id: 1, + EndpointId: 1, + Type: 1, // Swarm stack + Env: [ + { name: 'ENV1', value: 'value1' }, + { name: 'ENV2', value: 'value2' }, + ], + Option: { + Prune: false, + Force: false, + }, + AdditionalFiles: ['file1.yml', 'file2.yml'], + AutoUpdate: { + Interval: '5m', + Webhook: 'test-webhook-id', + ForceUpdate: false, + ForcePullImage: false, + }, + } as Stack, +}; + +type DeepPartial = T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + +function renderComponent(props: DeepPartial = {}) { + const Component = withTestQueryProvider( + withUserProvider(withTestRouter(StackRedeployGitForm)) + ); + // merge deep the props + return render(); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.tsx new file mode 100644 index 000000000..b47e8f6f0 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/StackRedeployGitForm.tsx @@ -0,0 +1,144 @@ +import { Formik, FormikHelpers } from 'formik'; +import { useState } from 'react'; +import { useRouter } from '@uirouter/react'; + +import { GitStackPayload, Stack, StackType } from '@/react/common/stacks/types'; +import { createWebhookId } from '@/portainer/helpers/webhookHelper'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; +import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; +import { + parseAutoUpdateResponse, + transformAutoUpdateViewModel, +} from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; +import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack'; +import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings'; + +import { useValidationSchema } from './useValidationSchema'; +import { FormValues } from './types'; +import { InnerForm } from './InnerForm'; + +export function StackRedeployGitForm({ stack }: { stack: Stack }) { + const router = useRouter(); + const deployMutation = useUpdateGitStack(stack.Id, stack.EndpointId); + const updateSettingsMutation = useUpdateGitStackSettings(); + + const validationSchema = useValidationSchema({ + isAuthEdit: !!stack.GitConfig?.Authentication, + }); + + const [webhookId] = useState(() => { + if (!stack.AutoUpdate?.Webhook) { + return createWebhookId(); + } + + return stack.AutoUpdate?.Webhook; + }); + + const authValues = stack.GitConfig?.Authentication; + const initialValues: FormValues = { + auth: { + NewCredentialName: '', + RepositoryAuthentication: !!authValues, + RepositoryAuthorizationType: authValues?.AuthorizationType, + RepositoryGitCredentialID: authValues?.GitCredentialID, + RepositoryPassword: authValues?.Password, + RepositoryUsername: authValues?.Username, + SaveCredential: false, + }, + autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), + env: stack.Env, + prune: stack.Option?.Prune || false, + refName: stack.GitConfig?.ReferenceName || '', + tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false, + }; + + return ( + + + + ); + + function handleSaveSettings( + values: FormValues, + { resetForm }: FormikHelpers + ) { + const autoUpdate = transformAutoUpdateViewModel( + values.autoUpdate, + webhookId + ); + const payload: GitStackPayload = { + AutoUpdate: autoUpdate, + env: values.env, + RepositoryReferenceName: values.refName, + RepositoryAuthentication: values.auth.RepositoryAuthentication, + RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID, + RepositoryUsername: values.auth.RepositoryUsername, + RepositoryPassword: values.auth.RepositoryPassword, + RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType, + prune: values.prune, + TLSSkipVerify: values.tlsSkipVerify, + }; + + updateSettingsMutation.mutate( + { + stackId: stack.Id, + endpointId: stack.EndpointId, + payload, + }, + { + onError(err) { + notifyError('Failure', err as Error, 'Unable to save stack settings'); + }, + onSuccess() { + notifySuccess('Success', 'Save stack settings successfully'); + resetForm({ values }); + }, + } + ); + } + + async function handleDeploy(values: FormValues) { + const isSwarmStack = stack.Type === StackType.DockerSwarm; + const result = await confirmStackUpdate( + 'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?', + isSwarmStack + ); + + if (!result) { + return; + } + + const payload: GitStackPayload = { + PullImage: result.pullImage, + env: values.env, + RepositoryReferenceName: values.refName, + RepositoryAuthentication: values.auth.RepositoryAuthentication, + RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID, + RepositoryUsername: values.auth.RepositoryUsername, + RepositoryPassword: values.auth.RepositoryPassword, + RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType, + prune: values.prune, + TLSSkipVerify: values.tlsSkipVerify, + }; + + deployMutation.mutate(payload, { + onError(err) { + notifyError('Failure', err as Error, 'Failed redeploying stack'); + }, + onSuccess() { + notifySuccess('Success', 'Pulled and redeployed stack successfully'); + router.stateService.reload(); + }, + }); + } +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/TLSVerificationField.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/TLSVerificationField.tsx new file mode 100644 index 000000000..6f6cfdd49 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/TLSVerificationField.tsx @@ -0,0 +1,36 @@ +import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils'; + +import { SwitchField } from '@@/form-components/SwitchField'; + +interface Props { + value: boolean; + initialValue: boolean; + onChange: (value: boolean) => void; +} + +export function TLSVerificationField({ value, initialValue, onChange }: Props) { + return ( +
+
+ { + if (initialValue && !newValue) { + const confirmed = await confirmEnableTLSVerify(); + if (!confirmed) { + return; + } + } + + onChange(newValue); + }} + data-cy="gitops-skip-tls-verification-switch" + /> +
+
+ ); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/types.ts b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/types.ts new file mode 100644 index 000000000..0eb3c7836 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/types.ts @@ -0,0 +1,14 @@ +import { GitAuthModel, AutoUpdateModel } from '@/react/portainer/gitops/types'; + +import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset'; + +export interface FormValues { + refName: string; + env: EnvVarValues; + prune: boolean; + + tlsSkipVerify: boolean; + + auth: GitAuthModel; + autoUpdate: AutoUpdateModel; +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/useValidationSchema.ts b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/useValidationSchema.ts new file mode 100644 index 000000000..663cb5726 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackRedeployGitForm/useValidationSchema.ts @@ -0,0 +1,29 @@ +import { array, boolean, number, object, SchemaOf, string } from 'yup'; + +import { useCurrentUser } from '@/react/hooks/useUser'; +import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service'; +import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset'; +import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation'; + +import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset'; + +import { FormValues } from './types'; + +export function useValidationSchema({ + isAuthEdit, +}: { + isAuthEdit: boolean; +}): SchemaOf { + const { user } = useCurrentUser(); + const gitCredentialsQuery = useGitCredentials(user.Id); + + return object({ + auth: gitAuthValidation(gitCredentialsQuery.data || [], isAuthEdit, false), + refName: string().default(''), + env: envVarValidation(), + prune: boolean().default(false), + registries: array(number().required()), + tlsSkipVerify: boolean().default(false), + autoUpdate: autoUpdateValidation(), + }); +} diff --git a/app/react/docker/stacks/view-models/stack.ts b/app/react/docker/stacks/view-models/stack.ts index 5a36e0eaa..48c45d591 100644 --- a/app/react/docker/stacks/view-models/stack.ts +++ b/app/react/docker/stacks/view-models/stack.ts @@ -6,6 +6,8 @@ import { RepoConfigResponse, } from '@/react/portainer/gitops/types'; +import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types'; + import { IResource } from '../../components/datatable/createOwnershipColumn'; export class StackViewModel implements IResource { @@ -19,7 +21,7 @@ export class StackViewModel implements IResource { SwarmId: string; - Env: { name: string; value: string }[]; + Env: EnvVar[]; Option: { Prune: boolean; Force: boolean } | undefined; diff --git a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx index 668e0d2b8..06d08cc87 100644 --- a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx +++ b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx @@ -5,6 +5,19 @@ import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect'; import { getAuthentication } from '../utils'; import { GitFormModel } from '../types'; +export type PathSelectorGitModel = Pick< + GitFormModel, + | 'RepositoryAuthentication' + | 'RepositoryPassword' + | 'RepositoryUsername' + | 'RepositoryGitCredentialID' + | 'RepositoryAuthorizationType' + | 'RepositoryURL' + | 'RepositoryReferenceName' + | 'TLSSkipVerify' + | 'RepositoryURLValid' +>; + export function PathSelector({ value, onChange, @@ -18,7 +31,7 @@ export function PathSelector({ value: string; onChange(value: string): void; placeholder: string; - model: GitFormModel; + model: PathSelectorGitModel; dirOnly?: boolean; readOnly?: boolean; inputId: string; diff --git a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx index 3c143394a..b8b719fb3 100644 --- a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx +++ b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import { FormikErrors } from 'formik'; -import { GitFormModel } from '@/react/portainer/gitops/types'; -import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector'; +import { + PathSelector, + PathSelectorGitModel, +} from '@/react/portainer/gitops/ComposePathField/PathSelector'; import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils'; import { SwitchField } from '@@/form-components/SwitchField'; @@ -16,7 +18,7 @@ import { RelativePathModel, getPerDevConfigsFilterType } from './types'; interface Props { values: RelativePathModel; - gitModel?: GitFormModel; + gitModel?: PathSelectorGitModel; onChange: (value: RelativePathModel) => void; isEditing?: boolean; hideEdgeConfigs?: boolean; diff --git a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx b/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx deleted file mode 100644 index 89a91e0b0..000000000 --- a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx +++ /dev/null @@ -1,505 +0,0 @@ -import { useState, useCallback, useEffect } from 'react'; -import { RefreshCw } from 'lucide-react'; -import { useRouter } from '@uirouter/react'; - -import { GitStackPayload, StackType } from '@/react/common/stacks/types'; -import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; -import { - baseStackWebhookUrl, - createWebhookId, -} from '@/portainer/helpers/webhookHelper'; -import { notifyError, notifySuccess } from '@/portainer/services/notifications'; - -import { Icon } from '@@/Icon'; -import { LoadingButton } from '@@/buttons/LoadingButton'; -import { Button } from '@@/buttons/Button'; -import { SwitchField } from '@@/form-components/SwitchField/SwitchField'; -import { FormSection } from '@@/form-components/FormSection/FormSection'; -import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset/StackEnvironmentVariablesPanel'; - -import { useUpdateGitStack } from '../queries/useUpdateGitStack'; -import { useUpdateGitStackSettings } from '../queries/useUpdateGitStackSettings'; -import { GitFormModel, AutoUpdateModel, AutoUpdateResponse } from '../types'; -import { RelativePathModel } from '../RelativePathFieldset/types'; -import { - parseAutoUpdateResponse, - transformAutoUpdateViewModel, -} from '../AutoUpdateFieldset/utils'; -import { confirmEnableTLSVerify } from '../utils'; -import { InfoPanel } from '../InfoPanel'; -import { AutoUpdateFieldset } from '../AutoUpdateFieldset'; -import { RefField } from '../RefField'; -import { AuthFieldset } from '../AuthFieldset'; -import { RelativePathFieldset } from '../RelativePathFieldset/RelativePathFieldset'; - -interface StackRedeployGitFormModel extends GitFormModel { - RefName?: string; - Env: Array<{ name: string; value: string }>; - Option: { - Prune: boolean; - }; - PullImage: boolean; - isEdit?: boolean; - isAuthEdit?: boolean; - TLSSkipVerify?: boolean; -} - -interface StackRedeployGitFormProps { - model: { - URL: string; - ReferenceName: string; - ConfigFilePath: string; - ConfigHash: string; - TLSSkipVerify: boolean; - }; - stack: { - Id: number; - EndpointId: number; - Type: number; - Env: Array<{ name: string; value: string }>; - Option?: { - Prune: boolean; - }; - AdditionalFiles?: string[]; - AutoUpdate?: AutoUpdateResponse; - GitConfig?: { - Authentication?: { - Username?: string; - Password?: string; - GitCredentialID?: number; - }; - }; - }; - endpoint: { - apiVersion: number; - Id: number; - }; -} - -interface StackRedeployGitFormState { - inProgress: boolean; - redeployInProgress: boolean; - showConfig: boolean; - hasUnsavedChanges: boolean; - baseWebhookUrl: string; - webhookId: string; -} - -export function StackRedeployGitForm({ - model, - stack, - endpoint, -}: StackRedeployGitFormProps) { - const router = useRouter(); - const [state, setState] = useState(() => ({ - inProgress: false, - redeployInProgress: false, - showConfig: false, - hasUnsavedChanges: false, - baseWebhookUrl: baseStackWebhookUrl(), - webhookId: buildStackWebhookId({ - prev: undefined, - stackWebhookId: stack.AutoUpdate?.Webhook, - }), - })); - - const [formValues, setFormValues] = useState({ - RepositoryURL: model.URL, - RepositoryURLValid: true, - ComposeFilePathInRepository: model.ConfigFilePath, - RefName: model.ReferenceName, - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - RepositoryGitCredentialID: 0, - SaveCredential: true, - NewCredentialName: '', - Env: stack.Env || [], - Option: { - Prune: Boolean(stack.Option?.Prune || false), - }, - PullImage: false, - AutoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), - TLSSkipVerify: model.TLSSkipVerify, - }); - - const [savedFormValues, setSavedFormValues] = - useState({ ...formValues }); - - // Use the new git stack mutation hooks - const updateGitStackMutation = useUpdateGitStack(stack.Id, stack.EndpointId); - const updateGitStackSettingsMutation = useUpdateGitStackSettings(); - - // Initialize form values from stack data - const initializeFormValues = useCallback(() => { - // Extract webhook ID first - only generate if not already set - setState((prev) => { - const webhookId = buildStackWebhookId({ - prev: prev.webhookId, - stackWebhookId: stack.AutoUpdate?.Webhook, - }); - return { ...prev, webhookId }; - }); - - const newFormValues = { - RepositoryURL: model.URL, - RepositoryURLValid: true, - ComposeFilePathInRepository: model.ConfigFilePath, - RefName: model.ReferenceName, - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - RepositoryGitCredentialID: 0, - SaveCredential: true, - NewCredentialName: '', - Env: stack.Env || [], - Option: { - Prune: Boolean(stack.Option?.Prune || false), - }, - PullImage: false, - AutoUpdate: parseAutoUpdateResponse(stack.AutoUpdate), - TLSSkipVerify: model.TLSSkipVerify, - }; - - if (stack.GitConfig?.Authentication) { - const auth = stack.GitConfig.Authentication; - newFormValues.RepositoryUsername = auth.Username || ''; - newFormValues.RepositoryPassword = auth.Password || ''; - newFormValues.RepositoryAuthentication = true; - - if (auth.GitCredentialID && auth.GitCredentialID > 0) { - newFormValues.SaveCredential = false; - newFormValues.RepositoryGitCredentialID = auth.GitCredentialID; - } - } - - setFormValues(newFormValues); - setSavedFormValues({ ...newFormValues }); - }, [model, stack]); - - // Initialize form values from stack data - useEffect(() => { - initializeFormValues(); - }, [initializeFormValues]); - - const handleChange = useCallback( - (partialValue: Partial) => { - setFormValues((prev) => { - const newValues = { ...prev, ...partialValue }; - const hasChanges = - JSON.stringify(savedFormValues) !== JSON.stringify(newValues); - setState((statePrev) => ({ - ...statePrev, - hasUnsavedChanges: hasChanges, - })); - return newValues; - }); - }, - [savedFormValues] - ); - - const handleChangeTLSSkipVerify = useCallback( - async (value: boolean) => { - if (model.TLSSkipVerify && !value) { - const confirmed = await confirmEnableTLSVerify(); - if (!confirmed) { - return; - } - } - handleChange({ TLSSkipVerify: value }); - }, - [model.TLSSkipVerify, handleChange] - ); - - const handleChangeAutoUpdate = useCallback( - (values: Partial) => { - setFormValues((prev) => { - const newValues = { - ...prev, - AutoUpdate: { - ...prev.AutoUpdate!, - ...values, - }, - }; - const hasChanges = - JSON.stringify(savedFormValues) !== JSON.stringify(newValues); - setState((statePrev) => ({ - ...statePrev, - hasUnsavedChanges: hasChanges, - })); - return newValues; - }); - }, - [savedFormValues] - ); - - const handleSubmit = useCallback(async () => { - const isSwarmStack = stack.Type === StackType.DockerSwarm; - const result = await confirmStackUpdate( - 'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?', - isSwarmStack - ); - - if (!result) { - return; - } - - try { - setState((prev) => ({ ...prev, redeployInProgress: true })); - - const payload: GitStackPayload = { - env: formValues.Env, - prune: formValues.Option.Prune, - RepositoryReferenceName: formValues.RefName, - RepositoryAuthentication: formValues.RepositoryAuthentication, - RepositoryGitCredentialID: formValues.RepositoryGitCredentialID, - RepositoryUsername: formValues.RepositoryUsername, - RepositoryPassword: formValues.RepositoryPassword, - PullImage: result.pullImage, - }; - - await updateGitStackMutation.mutateAsync(payload); - - notifySuccess('Success', 'Pulled and redeployed stack successfully'); - router.stateService.reload(); - } catch (err) { - notifyError('Failure', err as Error, 'Failed redeploying stack'); - } finally { - setState((prev) => ({ ...prev, redeployInProgress: false })); - } - }, [ - stack.Type, - formValues.Env, - formValues.Option.Prune, - formValues.RefName, - formValues.RepositoryAuthentication, - formValues.RepositoryGitCredentialID, - formValues.RepositoryUsername, - formValues.RepositoryPassword, - updateGitStackMutation, - router.stateService, - ]); - - const handleSaveSettings = useCallback(async () => { - try { - setState((prev) => ({ ...prev, inProgress: true })); - - const autoUpdate = transformAutoUpdateViewModel( - formValues.AutoUpdate, - state.webhookId - ); - const payload: GitStackPayload = { - AutoUpdate: autoUpdate, - env: formValues.Env, - RepositoryReferenceName: formValues.RefName, - RepositoryAuthentication: formValues.RepositoryAuthentication, - RepositoryGitCredentialID: formValues.RepositoryGitCredentialID, - RepositoryUsername: formValues.RepositoryUsername, - RepositoryPassword: formValues.RepositoryPassword, - prune: formValues.Option.Prune, - TLSSkipVerify: formValues.TLSSkipVerify, - }; - - await updateGitStackSettingsMutation.mutateAsync({ - stackId: stack.Id, - endpointId: stack.EndpointId, - payload, - }); - - notifySuccess('Success', 'Save stack settings successfully'); - - setSavedFormValues(JSON.parse(JSON.stringify(formValues))); - setState((prev) => ({ - ...prev, - hasUnsavedChanges: false, - inProgress: false, - })); - } catch (err) { - notifyError('Failure', err as Error, 'Unable to save stack settings'); - setState((prev) => ({ ...prev, inProgress: false })); - } - }, [ - formValues, - state.webhookId, - stack.Id, - stack.EndpointId, - updateGitStackSettingsMutation, - ]); - - function disableSaveSettingsButton(): boolean { - return Boolean( - state.inProgress || - state.redeployInProgress || - !state.hasUnsavedChanges || - (formValues.RepositoryAuthentication && - !formValues.RepositoryPassword && - formValues.RepositoryGitCredentialID === 0) || - (formValues.RepositoryAuthentication && - formValues.RepositoryPassword && - formValues.SaveCredential && - !formValues.NewCredentialName) - ); - } - - return ( -
- - - - - -
-
- -
-
- - {state.showConfig && ( - <> - handleChange({ RefName: value })} - model={formValues} - isUrlValid - stackId={stack.Id} - /> - - ) => handleChange(values)} - isAuthExplanationVisible - /> - -
-
- -
-
- - {}} - /> - - )} - - ) => - handleChange({ Env: value }) - } - showHelpMessage - isFoldable - /> - - {stack.Type === StackType.DockerSwarm && - endpoint.apiVersion >= 1.27 && ( - -
-
- - handleChange({ Option: { Prune: value } }) - } - data-cy="stack-prune-services-switch" - /> -
-
-
- )} - - - - - Pull and redeploy - - - - Save settings - - -
-
- ); -} - -function buildStackWebhookId({ - prev, - stackWebhookId, -}: { - prev?: string; - stackWebhookId: string | undefined; -}) { - if (prev) { - return prev; - } - - if (!stackWebhookId) { - return createWebhookId(); - } - - return stackWebhookId; -} diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 471a50df5..08382295e 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -1,4 +1,7 @@ -import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types'; +import { + AuthTypeOption, + GitCredential, +} from '@/react/portainer/account/git-credentials/types'; export type AutoUpdateMechanism = 'Webhook' | 'Interval'; export { type RelativePathModel } from './RelativePathFieldset/types'; @@ -20,6 +23,7 @@ export interface AutoUpdateResponse { export interface GitAuthenticationResponse { Username?: string; Password?: string; + AuthorizationType?: AuthTypeOption; GitCredentialID?: number; } @@ -44,7 +48,7 @@ export type GitCredentialsModel = { RepositoryAuthentication?: boolean; RepositoryUsername?: string; RepositoryPassword?: string; - RepositoryGitCredentialID?: number; + RepositoryGitCredentialID?: GitCredential['id']; RepositoryAuthorizationType?: AuthTypeOption; }; @@ -55,7 +59,7 @@ export type GitNewCredentialModel = { export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel; -export type DeployMethod = 'compose' | 'manifest'; +export type DeployMethod = 'compose' | 'manifest' | 'helm'; export interface GitFormModel extends GitAuthModel { RepositoryURL: string; @@ -101,6 +105,7 @@ export function toGitFormModel( ), RepositoryUsername: Authentication?.Username, RepositoryPassword: Authentication?.Password, + RepositoryAuthorizationType: Authentication?.AuthorizationType, RepositoryGitCredentialID: Authentication?.GitCredentialID, TLSSkipVerify, AutoUpdate: autoUpdate,