diff --git a/app/portainer/react/components/stacks.ts b/app/portainer/react/components/stacks.ts index a3d5cfb7d..1f9a582bd 100644 --- a/app/portainer/react/components/stacks.ts +++ b/app/portainer/react/components/stacks.ts @@ -6,6 +6,7 @@ import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplic import { StackEditorTab } from '@/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { StackInfoTab } from '@/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab'; export const stacksModule = angular .module('portainer.app.react.components.stacks', []) @@ -32,4 +33,19 @@ export const stacksModule = angular 'originalContainerNames', 'onSubmitSettled', ]) + ) + + .component( + 'stackInfoTab', + r2a(withUIRouter(withCurrentUser(StackInfoTab)), [ + 'stack', + 'stackName', + 'stackFileContent', + 'isRegular', + 'isExternal', + 'isOrphaned', + 'environmentId', + 'isOrphanedRunning', + 'yamlError', + ]) ).name; diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 3ae516ea9..8f5219ba7 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -11,125 +11,17 @@ Stack -
- -
-
Information
-
- -

- - This stack was created outside of Portainer. Control over this stack is limited. - This stack is orphaned. You can re-associate it with the current environment using the "Associate to this environment" feature. -

-
-
-
- - -
-
Stack details
-
- {{ stackName }} - - - - - - - - - -
-
- - - -
-
Associate to this environment
-

This feature allows you to re-associate this stack to the current environment.

-
- -
-
- - {{ state.formValidationError }} -
-
-
-
- - -
- - - - - -
-
+ diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index 5fcd19625..e0ec4095d 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -3,10 +3,6 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { StackStatus, StackType } from '@/react/common/stacks/types'; import { extractContainerNames } from '@/react/docker/stacks/ItemView/container-names'; -import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; -import { confirm, confirmDelete } from '@@/modals/confirm'; -import { ModalType } from '@@/modals'; -import { buildConfirmButton } from '@@/modals/utils'; angular.module('portainer.app').controller('StackController', [ '$async', @@ -25,7 +21,6 @@ angular.module('portainer.app').controller('StackController', [ 'Notifications', 'FormHelper', 'StackHelper', - 'ResourceControlService', 'Authentication', 'ContainerHelper', 'endpoint', @@ -46,7 +41,6 @@ angular.module('portainer.app').controller('StackController', [ Notifications, FormHelper, StackHelper, - ResourceControlService, Authentication, ContainerHelper, endpoint @@ -60,7 +54,6 @@ angular.module('portainer.app').controller('StackController', [ }; $scope.endpoint = endpoint; - $scope.isAdmin = Authentication.isAdmin(); $scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK; $scope.stackPullImageFeature = FeatureId.STACK_PULL_IMAGE; $scope.state = { @@ -78,68 +71,6 @@ angular.module('portainer.app').controller('StackController', [ $scope.state.showEditorTab = true; }; - $scope.removeStack = function () { - confirmDelete('Do you want to remove the stack? Associated services will be removed as well').then((confirmed) => { - if (!confirmed) { - return; - } - deleteStack(); - }); - }; - - $scope.detachStackFromGit = function () { - confirmDetachment().then(function onConfirm(confirmed) { - if (!confirmed) { - return; - } - - deployStack({ - stackFileContent: $scope.stackFileContent, - environmentVariables: FormHelper.removeInvalidEnvVars($scope.stack.Env), - prune: false, - }); - }); - }; - - function deleteStack() { - var endpointId = +$state.params.endpointId; - var stack = $scope.stack; - - StackService.remove(stack, $transition$.params().external, endpointId) - .then(function success() { - Notifications.success('Stack successfully removed', stack.Name); - $state.go('docker.stacks'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); - }); - } - - $scope.associateStack = function () { - var endpointId = +$state.params.endpointId; - var stack = $scope.stack; - var accessControlData = $scope.formValues.AccessControlData; - $scope.state.actionInProgress = true; - - StackService.associate(stack, endpointId, $scope.orphanedRunning) - .then(function success(data) { - const resourceControl = data.ResourceControl; - const userDetails = Authentication.getUserDetails(); - const userId = userDetails.ID; - return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); - }) - .then(function success() { - Notifications.success('Stack successfully associated', stack.Name); - $state.go('docker.stacks'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to associate stack ' + stack.Name); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - }; - $scope.onEditorSubmit = function () { $scope.state.actionInProgress = true; }; @@ -148,86 +79,6 @@ angular.module('portainer.app').controller('StackController', [ $scope.state.actionInProgress = false; }; - /** - * Deploy a stack - * @param {Object} stack - * @param {string} stack.stackFileContent - The stack file content to deploy - * @param {import('@@/form-components/EnvironmentVariablesFieldset').EnvVarValues} stack.environmentVariables - Array of environment variables - * @param {boolean} stack.prune - Whether to prune services that are no longer referenced - * @returns {void} - */ - function deployStack({ stackFileContent, environmentVariables, prune }) { - const stack = $scope.stack; - const isSwarmStack = stack.Type === 1; - confirmStackUpdate('Do you want to force an update of the stack?', isSwarmStack).then(function (result) { - if (!result) { - return; - } - - // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, we can pass - // the current endpoint identifier as a part of the update request. It will be used if - // the EndpointID property is not defined on the stack. - if (!stack.EndpointId) { - stack.EndpointId = endpoint.Id; - } - - $scope.state.actionInProgress = true; - StackService.updateStack(stack, stackFileContent, environmentVariables, prune, result.pullImage) - .then(function success() { - Notifications.success('Success', 'Stack successfully deployed'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to create stack'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - }); - } - - $scope.stopStack = stopStack; - function stopStack() { - return $async(stopStackAsync); - } - async function stopStackAsync() { - const confirmed = await confirm({ - title: 'Are you sure?', - modalType: ModalType.Warn, - message: 'Are you sure you want to stop this stack?', - confirmButton: buildConfirmButton('Stop', 'danger'), - }); - if (!confirmed) { - return; - } - - $scope.state.actionInProgress = true; - try { - await StackService.stop(endpoint.Id, $scope.stack.Id); - $state.reload(); - } catch (err) { - Notifications.error('Failure', err, 'Unable to stop stack'); - } - $scope.state.actionInProgress = false; - } - - $scope.startStack = startStack; - function startStack() { - return $async(startStackAsync); - } - async function startStackAsync() { - $scope.state.actionInProgress = true; - const id = $scope.stack.Id; - try { - await StackService.start(endpoint.Id, id); - $state.reload(); - } catch (err) { - Notifications.error('Failure', err, 'Unable to start stack'); - } - $scope.state.actionInProgress = false; - } - function loadStack(id) { return $async(async () => { var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; @@ -399,12 +250,3 @@ angular.module('portainer.app').controller('StackController', [ initView(); }, ]); - -function confirmDetachment() { - return confirm({ - modalType: ModalType.Warn, - title: 'Are you sure?', - message: 'Do you want to detach the stack from Git?', - confirmButton: buildConfirmButton('Detach', 'danger'), - }); -} diff --git a/app/react/common/stacks/queries/query-keys.ts b/app/react/common/stacks/queries/query-keys.ts index eb27c7682..4ccdd662a 100644 --- a/app/react/common/stacks/queries/query-keys.ts +++ b/app/react/common/stacks/queries/query-keys.ts @@ -1,7 +1,7 @@ import { StackId } from '../types'; export const queryKeys = { - base: () => ['stacks'], + base: () => ['stacks'] as const, stack: (stackId?: StackId) => [...queryKeys.base(), stackId] as const, stackFile: (stackId?: StackId, params?: unknown) => [...queryKeys.stack(stackId), 'file', params] as const, diff --git a/app/react/common/stacks/queries/useDeleteStackByNameMutation.ts b/app/react/common/stacks/queries/useDeleteStackByNameMutation.ts new file mode 100644 index 000000000..a8a7066e1 --- /dev/null +++ b/app/react/common/stacks/queries/useDeleteStackByNameMutation.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withGlobalError } from '@/react-tools/react-query'; + +import { StackId } from '../types'; + +import { queryKeys } from './query-keys'; + +interface DeleteStackParams { + stackId: StackId; + stackName: string; + environmentId: EnvironmentId; + namespace: string; +} + +export function useDeleteStackByNameMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteStackByName, + onSuccess: (_, variables) => { + // Invalidate the specific stack query + queryClient.invalidateQueries(queryKeys.stack(variables.stackId)); + // Invalidate all stacks queries + queryClient.invalidateQueries(queryKeys.base()); + }, + ...withGlobalError('Unable to delete stack'), + }); +} + +async function deleteStackByName({ + environmentId, + namespace, + stackName, +}: DeleteStackParams) { + try { + await axios.delete(`/stacks/name/${stackName}`, { + params: { + external: false, + name: stackName, + endpointId: environmentId, + namespace, + }, + }); + } catch (error) { + throw parseAxiosError(error, 'Unable to delete stack'); + } +} diff --git a/app/react/common/stacks/queries/useDeleteStackMutation.ts b/app/react/common/stacks/queries/useDeleteStackMutation.ts new file mode 100644 index 000000000..d19acaec7 --- /dev/null +++ b/app/react/common/stacks/queries/useDeleteStackMutation.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withGlobalError } from '@/react-tools/react-query'; + +import { StackId } from '../types'; + +import { queryKeys } from './query-keys'; + +interface DeleteStackParams { + id?: StackId; + name?: string; + external: boolean; + environmentId: EnvironmentId; +} + +export function useDeleteStackMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteStack, + onSuccess: () => queryClient.invalidateQueries(queryKeys.base()), + ...withGlobalError('Unable to delete stack'), + }); +} + +async function deleteStack({ + id, + name, + external, + environmentId, +}: DeleteStackParams) { + try { + await axios.delete(`/stacks/${id || name}`, { + params: { + external, + endpointId: environmentId, + }, + }); + } catch (error) { + throw parseAxiosError(error, 'Unable to delete stack'); + } +} diff --git a/app/react/components/buttons/Button.tsx b/app/react/components/buttons/Button.tsx index 24be9f640..f53561a9c 100644 --- a/app/react/components/buttons/Button.tsx +++ b/app/react/components/buttons/Button.tsx @@ -24,6 +24,7 @@ type Color = | 'dangerlight' | 'warninglight' | 'warning' + | 'success' | 'none'; type Size = 'xsmall' | 'small' | 'medium' | 'large'; diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.test.tsx new file mode 100644 index 000000000..ea29b567b --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.test.tsx @@ -0,0 +1,310 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { delay, http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { createMockUsers } from '@/react-tools/test-mocks'; +import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm'; + +import { AssociateStackForm } from './AssociateStackForm'; + +// Mock the useSwarmId hook to avoid React Query complexity +vi.mock('@/react/docker/proxy/queries/useSwarm', () => ({ + useSwarmId: vi.fn(), +})); + +// Mock the AccessControlForm to simplify testing +vi.mock('@/react/portainer/access-control', () => ({ + AccessControlForm: vi.fn(({ onChange, values }) => ( +
+ +
+ )), +})); + +beforeEach(() => { + vi.mocked(useSwarmId).mockReturnValue({ + data: undefined, + } as ReturnType); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +it('should render correctly', () => { + renderComponent(); + + expect(screen.getByText('Associate to this environment')).toBeVisible(); + expect( + screen.getByText(/This feature allows you to re-associate this stack/i) + ).toBeVisible(); + + expect(screen.getByTestId('access-control-form')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Associate' })).toBeVisible(); +}); + +describe('form submission', () => { + it('should call mutation with correct payload on submit', async () => { + let requestUrl = ''; + + server.use( + http.put<{ id: string }>( + '/api/stacks/:id/associate', + async ({ request, params }) => { + requestUrl = request.url; + return HttpResponse.json(createMockStackResponse(params.id)); + } + ), + http.put('/api/resource_controls/:id', async ({ request }) => { + await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + + const user = userEvent.setup(); + renderComponent({ + environmentId: 5, + stackId: 123, + isOrphanedRunning: true, + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Associate' })).toBeVisible(); + }); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + await waitFor(() => { + expect(requestUrl).toContain('endpointId=5'); + expect(requestUrl).toContain('orphanedRunning=true'); + }); + }); + + it('should show loading state during submission', async () => { + let associateCalled = false; + + server.use( + http.put('/api/stacks/:id/associate', async () => { + associateCalled = true; + await delay(50); + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const user = userEvent.setup(); + renderComponent(); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + // Check for loading text + expect(screen.getByText(/association in progress/i)).toBeVisible(); + + // Wait for API call + await waitFor( + () => { + expect(associateCalled).toBe(true); + }, + { timeout: 2000 } + ); + }); + + it('should complete association successfully', async () => { + let associateCalled = false; + let resourceControlCalled = false; + + server.use( + http.put('/api/stacks/:id/associate', () => { + associateCalled = true; + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => { + resourceControlCalled = true; + return HttpResponse.json({ success: true }); + }) + ); + + const user = userEvent.setup(); + renderComponent({ stackName: 'my-stack' }); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + // Verify both API calls were made + await waitFor( + () => { + expect(associateCalled).toBe(true); + expect(resourceControlCalled).toBe(true); + }, + { timeout: 3000 } + ); + }); +}); + +describe('swarmId integration', () => { + it('should pass swarmId when environment is in swarm mode', async () => { + let requestUrl = ''; + + vi.mocked(useSwarmId).mockReturnValue({ + data: 'swarm-id-123', + } as ReturnType); + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + requestUrl = request.url; + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const user = userEvent.setup(); + renderComponent({ environmentId: 1 }); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + await waitFor(() => { + expect(requestUrl).toContain('swarmId=swarm-id-123'); + }); + }); + + it('should not pass swarmId when environment is not in swarm mode', async () => { + let requestUrl = ''; + + vi.mocked(useSwarmId).mockReturnValue({ + data: undefined, + } as ReturnType); + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + requestUrl = request.url; + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const user = userEvent.setup(); + renderComponent(); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + // Verify swarmId is not included in the request + await waitFor(() => { + expect(requestUrl).not.toContain('swarmId'); + }); + }); +}); + +describe('orphanedRunning parameter', () => { + it('should pass isOrphanedRunning=true when provided', async () => { + let requestUrl = ''; + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + requestUrl = request.url; + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const user = userEvent.setup(); + renderComponent({ isOrphanedRunning: true }); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + await waitFor(() => { + expect(requestUrl).toContain('orphanedRunning=true'); + }); + }); + + it('should pass isOrphanedRunning=false when undefined', async () => { + let requestUrl = ''; + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + requestUrl = request.url; + return HttpResponse.json(createMockStackResponse()); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const user = userEvent.setup(); + renderComponent({ isOrphanedRunning: undefined }); + + const associateButton = screen.getByRole('button', { name: 'Associate' }); + await user.click(associateButton); + + await waitFor(() => { + expect(requestUrl).toContain('orphanedRunning=false'); + }); + }); +}); + +function renderComponent({ + stackName = 'test-stack', + environmentId = 1, + stackId = 123, + isOrphanedRunning, +}: Partial> = {}) { + const users = createMockUsers(1, [1]); + + server.use( + http.get('/api/users/:id', () => HttpResponse.json(users[0])), + http.get('/api/endpoints/:id/docker/swarm', () => + HttpResponse.json({ message: 'Not in swarm mode' }, { status: 503 }) + ) + ); + + const Wrapped = withTestQueryProvider( + withTestRouter(withUserProvider(AssociateStackForm)) + ); + + return render( + + ); +} + +function createMockStackResponse(stackId = '123') { + return { + Id: stackId, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: stackId, + Type: 6, + }, + }; +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.tsx new file mode 100644 index 000000000..d83a1e81b --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/AssociateStackForm.tsx @@ -0,0 +1,115 @@ +import { RefreshCw } from 'lucide-react'; +import { Form, Formik, useFormikContext } from 'formik'; +import { object } from 'yup'; +import { useRouter } from '@uirouter/react'; + +import { AccessControlForm } from '@/react/portainer/access-control'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { validationSchema as accessControlValidation } from '@/react/portainer/access-control/AccessControlForm/AccessControlForm.validation'; +import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { LoadingButton } from '@@/buttons'; +import { FormSection } from '@@/form-components/FormSection'; + +import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation'; + +function validationSchema({ isAdmin }: { isAdmin: boolean }) { + return object({ + accessControl: accessControlValidation(isAdmin), + }); +} + +export function AssociateStackForm({ + stackName, + environmentId, + stackId, + isOrphanedRunning, +}: { + stackName: string; + environmentId: EnvironmentId; + stackId: number; + isOrphanedRunning: boolean | undefined; +}) { + const router = useRouter(); + const swarmIdQuery = useSwarmId(environmentId); + const mutation = useAssociateStackToEnvironmentMutation(); + + const { user } = useCurrentUser(); + const { isAdmin } = useIsEdgeAdmin(); + const initialValues: FormValues = { + accessControl: parseAccessControlFormData(isAdmin, user.Id), + }; + + return ( + +

+ This feature allows you to re-associate this stack to the current + environment. +

+ + { + mutation.mutate( + { + environmentId, + accessControl: values.accessControl, + swarmId: swarmIdQuery.data, + isOrphanedRunning, + stackId, + }, + { + onSuccess() { + notifySuccess('Stack successfully associated', stackName); + router.stateService.go('docker.stacks'); + }, + } + ); + }} + validateOnMount + validationSchema={() => validationSchema({ isAdmin })} + > + + +
+ ); +} +type FormValues = { + accessControl: AccessControlFormData; +}; + +function InnerForm({ environmentId }: { environmentId: EnvironmentId }) { + const { values, setFieldValue, errors, isSubmitting } = + useFormikContext(); + + return ( +
+ setFieldValue('accessControl', newValues)} + hideTitle + environmentId={environmentId} + errors={errors.accessControl} + /> +
+
+ + Associate + +
+
+ + ); +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx new file mode 100644 index 000000000..a183b4d54 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx @@ -0,0 +1,245 @@ +import { + ArrowRightIcon, + PlayIcon, + PlusIcon, + StopCircleIcon, + Trash2Icon, +} from 'lucide-react'; +import { useRouter } from '@uirouter/react'; + +import { Authorized } from '@/react/hooks/useUser'; +import { Stack, StackStatus } from '@/react/common/stacks/types'; +import { useDeleteStackMutation } from '@/react/common/stacks/queries/useDeleteStackMutation'; +import { notifyError, notifySuccess } from '@/portainer/services/notifications'; + +import { Button, LoadingButton } from '@@/buttons'; +import { Link } from '@@/Link'; +import { confirm, confirmDelete } from '@@/modals/confirm'; +import { ModalType } from '@@/modals/Modal/types'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { useUpdateStackMutation } from '../../useUpdateStack'; + +import { useStartStackMutation } from './useStartStackMutation'; +import { useStopStackMutation } from './useStopStackMutation'; + +export function StackActions({ + stack, + fileContent, + isRegular, + environmentId, + isExternal, +}: { + stack: Stack; + fileContent?: string; + isRegular?: boolean; + environmentId: number; + isExternal: boolean; +}) { + const router = useRouter(); + const startStackMutation = useStartStackMutation(); + const stopStackMutation = useStopStackMutation(); + const deleteStackMutation = useDeleteStackMutation(); + const detachFromGitMutation = useUpdateStackMutation(); + + const isMutating = + startStackMutation.isLoading || + stopStackMutation.isLoading || + deleteStackMutation.isLoading || + detachFromGitMutation.isLoading; + + const stackId = stack.Id; + const status = stack.Status; + + return ( +
+ {isRegular && ( + + {status === StackStatus.Active ? ( + + ) : ( + + )} + + )} + + + + + + {!!(isRegular && fileContent) && ( + + )} + + {!!( + isRegular && + fileContent && + !stack.FromAppTemplate && + stack.GitConfig + ) && ( + + handleDetachFromGit()} + disabled={isMutating} + data-cy="stack-detach-git-btn" + isLoading={detachFromGitMutation.isLoading} + loadingText="Detachment in progress..." + > + Detach from Git + + + )} +
+ ); + + async function handleStop() { + const confirmed = await confirm({ + title: 'Are you sure?', + modalType: ModalType.Warn, + message: 'Are you sure you want to stop this stack?', + confirmButton: buildConfirmButton('Stop', 'danger'), + }); + + if (!confirmed) { + return; + } + + stopStackMutation.mutate( + { id: stackId, environmentId }, + { + onError(err) { + notifyError('Failure', err as Error, 'Unable to stop stack'); + }, + onSuccess() { + notifySuccess('Success', `Stack ${stack.Name} stopped successfully`); + router.stateService.reload(); + }, + } + ); + } + + async function handleDelete() { + const confirmed = await confirmDelete( + 'Do you want to remove the stack? Associated services will be removed as well' + ); + if (!confirmed) { + return; + } + deleteStackMutation.mutate( + { + id: stack.Id, + name: stack.Name, + environmentId: stack.EndpointId, + external: isExternal, + }, + { + onError(err) { + notifyError( + 'Failure', + err as Error, + `Unable to remove stack ${stack.Name}` + ); + }, + onSuccess() { + notifySuccess('Stack successfully removed', stack.Name); + router.stateService.go('^'); + }, + } + ); + } + + async function handleDetachFromGit() { + const confirmed = await confirm({ + modalType: ModalType.Warn, + title: 'Are you sure?', + message: 'Do you want to detach the stack from Git?', + confirmButton: buildConfirmButton('Detach', 'danger'), + }); + + if (!confirmed) { + return; + } + + detachFromGitMutation.mutate( + { + environmentId, + stackId: stack.Id, + payload: { + stackFileContent: fileContent!, + env: stack.Env, + prune: false, + }, + }, + { + onSuccess() { + router.stateService.go('^'); + }, + } + ); + } +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx new file mode 100644 index 000000000..cf313ec36 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.test.tsx @@ -0,0 +1,360 @@ +import { render, screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; + +import { StackInfoTab } from './StackInfoTab'; + +// Mock the child components to isolate testing +vi.mock('./AssociateStackForm', () => ({ + AssociateStackForm: vi.fn(() => ( +
AssociateStackForm
+ )), +})); + +vi.mock('./StackActions', () => ({ + StackActions: vi.fn(() =>
StackActions
), +})); + +vi.mock( + '@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm', + () => ({ + StackDuplicationForm: vi.fn(() => ( +
StackDuplicationForm
+ )), + }) +); + +vi.mock( + '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm', + () => ({ + StackRedeployGitForm: vi.fn(() => ( +
StackRedeployGitForm
+ )), + }) +); + +describe('StackInfoTab', () => { + describe('initial rendering', () => { + it('should render stack name', () => { + renderComponent({ stackName: 'my-test-stack' }); + + expect(screen.getByText('my-test-stack')).toBeVisible(); + }); + + it('should render StackActions when stack exists', () => { + const mockStack = createMockStack(); + renderComponent({ stack: mockStack }); + + expect(screen.getByTestId('stack-actions')).toBeVisible(); + }); + + it('should not render StackActions when stack is undefined', () => { + renderComponent({ stack: undefined }); + + expect(screen.queryByTestId('stack-actions')).not.toBeInTheDocument(); + }); + + it('should render stack details section', () => { + renderComponent(); + + expect(screen.getByText('Stack details')).toBeVisible(); + }); + }); + + describe('external and orphaned warnings', () => { + it('should show external stack warning when isExternal is true', () => { + renderComponent({ isExternal: true }); + + expect( + screen.getByText(/This stack was created outside of Portainer/i) + ).toBeVisible(); + expect(screen.getByText('Information')).toBeVisible(); + }); + + it('should show orphaned stack warning when isOrphaned is true', () => { + renderComponent({ isOrphaned: true }); + + expect(screen.getByText(/This stack is orphaned/i)).toBeVisible(); + expect(screen.getByText(/Associate to this environment/i)).toBeVisible(); + }); + + it('should show orphaned warning when isOrphanedRunning is true', () => { + renderComponent({ isOrphanedRunning: true, isOrphaned: false }); + + expect(screen.getByText(/This stack is orphaned/i)).toBeVisible(); + }); + + it('should show orphaned warning when both isOrphaned and isOrphanedRunning are true', () => { + renderComponent({ isOrphaned: true, isOrphanedRunning: true }); + + expect(screen.getByText(/This stack is orphaned/i)).toBeVisible(); + }); + + it('should show both warnings when isExternal and isOrphaned are true', () => { + renderComponent({ isExternal: true, isOrphaned: true }); + + expect( + screen.getByText(/This stack was created outside of Portainer/i) + ).toBeVisible(); + expect(screen.getByText(/This stack is orphaned/i)).toBeVisible(); + }); + + it('should not show warnings when both isExternal and isOrphaned are false', () => { + renderComponent({ isExternal: false, isOrphaned: false }); + + expect(screen.queryByText('Information')).not.toBeInTheDocument(); + expect( + screen.queryByText(/This stack was created outside of Portainer/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/This stack is orphaned/i) + ).not.toBeInTheDocument(); + }); + }); + + describe('conditional form rendering', () => { + it('should render AssociateStackForm when stack is orphaned', () => { + const mockStack = createMockStack(); + renderComponent({ + stack: mockStack, + isOrphaned: true, + }); + + expect(screen.getByTestId('associate-stack-form')).toBeVisible(); + expect( + screen.queryByTestId('stack-duplication-form') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('stack-redeploy-git-form') + ).not.toBeInTheDocument(); + }); + + it('should not render AssociateStackForm when only isOrphanedRunning is true', () => { + const mockStack = createMockStack(); + renderComponent({ + stack: mockStack, + isOrphanedRunning: true, + isOrphaned: false, + }); + + // isOrphanedRunning alone doesn't trigger the form, only isOrphaned does + expect( + screen.queryByTestId('associate-stack-form') + ).not.toBeInTheDocument(); + // Should show duplication form instead if regular + expect(screen.getByTestId('stack-duplication-form')).toBeVisible(); + }); + + it('should render StackRedeployGitForm when stack has GitConfig and is not from template', () => { + const mockStack = createMockStack({ + GitConfig: { + URL: 'https://github.com/test/repo', + ReferenceName: 'main', + ConfigFilePath: 'docker-compose.yml', + ConfigHash: '', + TLSSkipVerify: false, + }, + FromAppTemplate: false, + }); + renderComponent({ + stack: mockStack, + isOrphaned: false, + isRegular: true, + }); + + expect(screen.getByTestId('stack-redeploy-git-form')).toBeVisible(); + }); + + it('should not render StackRedeployGitForm when stack is from app template', () => { + const mockStack = createMockStack({ + GitConfig: { + URL: 'https://github.com/test/repo', + ReferenceName: 'main', + ConfigFilePath: 'docker-compose.yml', + ConfigHash: '', + TLSSkipVerify: false, + }, + FromAppTemplate: true, + }); + renderComponent({ + stack: mockStack, + isOrphaned: false, + isRegular: true, + }); + + expect( + screen.queryByTestId('stack-redeploy-git-form') + ).not.toBeInTheDocument(); + }); + + it('should not render StackRedeployGitForm when stack has no GitConfig', () => { + const mockStack = createMockStack({ + GitConfig: undefined, + }); + renderComponent({ + stack: mockStack, + isOrphaned: false, + isRegular: true, + }); + + expect( + screen.queryByTestId('stack-redeploy-git-form') + ).not.toBeInTheDocument(); + }); + + it('should render StackDuplicationForm when stack is regular and not orphaned', () => { + const mockStack = createMockStack(); + renderComponent({ + stack: mockStack, + isRegular: true, + isOrphaned: false, + }); + + expect(screen.getByTestId('stack-duplication-form')).toBeVisible(); + }); + + it('should not render StackDuplicationForm when stack is not regular', () => { + const mockStack = createMockStack(); + renderComponent({ + stack: mockStack, + isRegular: false, + isOrphaned: false, + }); + + expect( + screen.queryByTestId('stack-duplication-form') + ).not.toBeInTheDocument(); + }); + + it('should not render any forms when stack is undefined', () => { + renderComponent({ stack: undefined }); + + expect( + screen.queryByTestId('associate-stack-form') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('stack-duplication-form') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('stack-redeploy-git-form') + ).not.toBeInTheDocument(); + }); + }); + + describe('git and duplication form combination', () => { + it('should render both StackRedeployGitForm and StackDuplicationForm when conditions met', () => { + const mockStack = createMockStack({ + GitConfig: { + URL: 'https://github.com/test/repo', + ReferenceName: 'main', + ConfigFilePath: 'docker-compose.yml', + ConfigHash: '', + TLSSkipVerify: false, + }, + FromAppTemplate: false, + }); + renderComponent({ + stack: mockStack, + isRegular: true, + isOrphaned: false, + }); + + expect(screen.getByTestId('stack-redeploy-git-form')).toBeVisible(); + expect(screen.getByTestId('stack-duplication-form')).toBeVisible(); + }); + }); + + describe('stack file content and environment id passing', () => { + it('should pass stackFileContent to child components', () => { + const mockStack = createMockStack(); + const stackFileContent = + 'version: "3"\nservices:\n web:\n image: nginx'; + renderComponent({ + stack: mockStack, + stackFileContent, + isRegular: true, + }); + + expect(screen.getByTestId('stack-actions')).toBeVisible(); + expect(screen.getByTestId('stack-duplication-form')).toBeVisible(); + }); + + it('should pass environmentId to child components', () => { + const mockStack = createMockStack(); + renderComponent({ + stack: mockStack, + environmentId: 42, + isRegular: true, + }); + + expect(screen.getByTestId('stack-actions')).toBeVisible(); + }); + }); +}); + +function renderComponent({ + stack, + stackName = 'test-stack', + stackFileContent, + isRegular = true, + isExternal = false, + isOrphaned = false, + isOrphanedRunning = false, + environmentId = 1, + yamlError, +}: Partial> = {}) { + // Mock the Docker API version endpoint + server.use( + http.get('/api/endpoints/:id/docker/version', () => + HttpResponse.json({ ApiVersion: '1.41' }) + ) + ); + + const Wrapped = withTestQueryProvider( + withTestRouter(withUserProvider(StackInfoTab)) + ); + + return render( + + ); +} + +function createMockStack(overrides?: Partial): Stack { + return { + Id: 1, + Name: 'test-stack', + Type: StackType.DockerCompose, + EndpointId: 1, + SwarmId: '', + EntryPoint: 'docker-compose.yml', + Env: [], + Status: StackStatus.Active, + ProjectPath: '/data/compose/1', + CreationDate: Date.now(), + CreatedBy: 'admin', + UpdateDate: Date.now(), + UpdatedBy: 'admin', + FromAppTemplate: false, + IsComposeFormat: true, + FilesystemPath: '/data/compose/1', + StackFileVersion: '3', + PreviousDeploymentInfo: null, + SupportRelativePath: false, + ...overrides, + }; +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx new file mode 100644 index 000000000..9eaa2fc89 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx @@ -0,0 +1,159 @@ +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 { StackActions } from './StackActions'; +import { AssociateStackForm } from './AssociateStackForm'; + +interface StackInfoTabProps { + stack?: Stack; // will be loaded only if regular or orphaned + stackName: string; + stackFileContent?: string; + isRegular?: boolean; + isExternal: boolean; + isOrphaned: boolean; + isOrphanedRunning: boolean; + environmentId: number; + yamlError?: string; +} + +export function StackInfoTab({ + stack, + stackName, + stackFileContent, + isRegular, + isExternal, + isOrphaned, + isOrphanedRunning, + environmentId, + + yamlError, +}: StackInfoTabProps) { + return ( + <> + + + +
+ {stackName} + + {stack && ( +
+ +
+ )} +
+
+ + {stack && ( + <> + {isOrphaned ? ( + + ) : ( + <> + {stack.GitConfig && !stack.FromAppTemplate && ( + + + + )} + + {isRegular && ( + + )} + + )} + + )} + + ); +} + +function ExternalOrphanedWarning({ + isExternal, + isOrphaned, +}: { + isExternal: boolean; + isOrphaned: boolean; +}) { + if (!isExternal && !isOrphaned) return null; + + return ( + +
+ +

+ + {isExternal && ( + + This stack was created outside of Portainer. Control over this + stack is limited. + + )} + {isOrphaned && ( + + This stack is orphaned. You can re-associate it with the current + environment using the "Associate to this environment" + feature. + + )} +

+
+
+
+ ); +} + +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/useAssociateStackToEnvironmentMutation.test.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx new file mode 100644 index 000000000..1a146564d --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.test.tsx @@ -0,0 +1,474 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { Stack } from '@/react/common/stacks/types'; +import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; + +import { useAssociateStackToEnvironmentMutation } from './useAssociateStackToEnvironmentMutation'; + +function renderMutationHook() { + const Wrapper = withTestQueryProvider(({ children }) => <>{children}); + + return renderHook(() => useAssociateStackToEnvironmentMutation(), { + wrapper: Wrapper, + }); +} + +describe('useAssociateStackToEnvironmentMutation', () => { + describe('successful association', () => { + it('should make PUT request to correct endpoint with params', async () => { + let requestUrl = ''; + let capturedParams: URLSearchParams | undefined; + + server.use( + http.put('/api/stacks/:id/associate', async ({ request, params }) => { + requestUrl = request.url; + capturedParams = new URL(request.url).searchParams; + return HttpResponse.json({ + Id: Number(params.id), + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: Number(params.id), + Type: 6, + }, + } as Partial); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 5, + stackId: 123, + isOrphanedRunning: true, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + swarmId: 'swarm-123', + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(requestUrl).toContain('/api/stacks/123/associate'); + expect(capturedParams?.get('endpointId')).toBe('5'); + expect(capturedParams?.get('orphanedRunning')).toBe('true'); + expect(capturedParams?.get('swarmId')).toBe('swarm-123'); + }); + + it('should apply resource control after association', async () => { + let resourceControlRequestBody: unknown; + + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 42, + ResourceId: 123, + Type: 6, + }, + } as Partial) + ), + http.put('/api/resource_controls/:id', async ({ request, params }) => { + resourceControlRequestBody = await request.json(); + expect(params.id).toBe('42'); + return HttpResponse.json({ success: true }); + }) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [1, 2], + authorizedTeams: [3], + }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(resourceControlRequestBody).toBeDefined(); + }); + + it('should handle optional swarmId parameter', async () => { + let capturedParams: URLSearchParams | undefined; + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + capturedParams = new URL(request.url).searchParams; + return HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + // swarmId is undefined + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // swarmId should not be in params when undefined + expect(capturedParams?.get('swarmId')).toBeNull(); + }); + + it('should default orphanedRunning to false when undefined', async () => { + let capturedParams: URLSearchParams | undefined; + + server.use( + http.put('/api/stacks/:id/associate', async ({ request }) => { + capturedParams = new URL(request.url).searchParams; + return HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + // isOrphanedRunning is undefined + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(capturedParams?.get('orphanedRunning')).toBe('false'); + }); + }); + + describe('error handling', () => { + let consoleError: ReturnType; + + beforeEach(() => { + // Suppress console.error for error tests to reduce noise + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('should handle API error when association fails', async () => { + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ message: 'Association failed' }, { status: 500 }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + }); + + it('should throw error when ResourceControl is missing from response', async () => { + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ + Id: 123, + Name: 'test-stack', + // ResourceControl is missing + } as Partial) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + expect((result.current.error as Error).message).toContain( + 'resource control expected after creation' + ); + }); + + it('should handle error when applying resource control fails', async () => { + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial) + ), + http.put('/api/resource_controls/:id', () => + HttpResponse.json( + { message: 'Failed to update resource control' }, + { status: 500 } + ) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + }); + }); + + describe('mutation states', () => { + it('should track loading state during mutation', async () => { + server.use( + http.put('/api/stacks/:id/associate', async () => { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + return HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial); + }), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const { result } = renderMutationHook(); + + expect(result.current.isLoading).toBe(false); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should return stack data on success', async () => { + const mockStack = { + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial; + + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json(mockStack) + ), + http.put('/api/resource_controls/:id', () => + HttpResponse.json({ success: true }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeUndefined(); // Mutation returns void after applying resource control + }); + }); + + describe('access control integration', () => { + it('should handle private ownership', async () => { + let resourceControlBody: unknown; + + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial) + ), + http.put('/api/resource_controls/:id', async ({ request }) => { + resourceControlBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.PRIVATE, + authorizedUsers: [], + authorizedTeams: [], + }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(resourceControlBody).toBeDefined(); + }); + + it('should handle restricted ownership with users and teams', async () => { + let resourceControlBody: unknown; + + server.use( + http.put('/api/stacks/:id/associate', () => + HttpResponse.json({ + Id: 123, + Name: 'test-stack', + ResourceControl: { + Id: 1, + ResourceId: 123, + Type: 6, + }, + } as Partial) + ), + http.put('/api/resource_controls/:id', async ({ request }) => { + resourceControlBody = await request.json(); + return HttpResponse.json({ success: true }); + }) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + environmentId: 1, + stackId: 123, + accessControl: { + ownership: ResourceControlOwnership.RESTRICTED, + authorizedUsers: [1, 2, 3], + authorizedTeams: [10, 20], + }, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(resourceControlBody).toBeDefined(); + }); + }); +}); diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.ts b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.ts new file mode 100644 index 000000000..2950ce35b --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/useAssociateStackToEnvironmentMutation.ts @@ -0,0 +1,70 @@ +import { useMutation } from '@tanstack/react-query'; + +import PortainerError from '@/portainer/error'; +import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import axios from '@/portainer/services/axios'; +import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl'; +import { Stack } from '@/react/common/stacks/types'; +import { withGlobalError } from '@/react-tools/react-query'; + +export function useAssociateStackToEnvironmentMutation() { + return useMutation({ + mutationFn: associateStackToEnvironmentMutation, + ...withGlobalError('Failed to associate stack to environment'), + }); +} + +async function associateStackToEnvironmentMutation({ + environmentId, + stackId, + isOrphanedRunning, + accessControl, + swarmId, +}: { + environmentId: number; + stackId: number; + isOrphanedRunning?: boolean; + accessControl: AccessControlFormData; + swarmId?: string; +}) { + const associatedStack = await associate({ + environmentId, + stackId, + isOrphanedRunning, + swarmId, + }); + + const resourceControl = associatedStack.ResourceControl; + if (!resourceControl) { + throw new PortainerError('resource control expected after creation'); + } + + await applyResourceControl(accessControl, resourceControl.Id); +} + +async function associate({ + environmentId, + stackId, + isOrphanedRunning, + swarmId, +}: { + environmentId: number; + stackId: number; + isOrphanedRunning?: boolean; + swarmId?: string; +}) { + const { data } = await axios.put( + buildStackUrl(stackId, 'associate'), + {}, + { + params: { + endpointId: environmentId, + orphanedRunning: isOrphanedRunning ?? false, + swarmId, + }, + } + ); + + return data; +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/useStartStackMutation.ts b/app/react/docker/stacks/ItemView/StackInfoTab/useStartStackMutation.ts new file mode 100644 index 000000000..de0c7674f --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/useStartStackMutation.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl'; +import { Stack } from '@/react/common/stacks/types'; + +export function useStartStackMutation() { + return useMutation({ + mutationFn: startStack, + }); +} + +async function startStack({ + id, + environmentId, +}: { + id: Stack['Id']; + environmentId?: number; +}) { + try { + const { data } = await axios.post( + buildStackUrl(id, 'start'), + undefined, + { params: { endpointId: environmentId } } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to start stack'); + } +} diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/useStopStackMutation.ts b/app/react/docker/stacks/ItemView/StackInfoTab/useStopStackMutation.ts new file mode 100644 index 000000000..a2c4ae39c --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackInfoTab/useStopStackMutation.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { buildStackUrl } from '@/react/common/stacks/queries/buildUrl'; +import { Stack } from '@/react/common/stacks/types'; + +export function useStopStackMutation() { + return useMutation({ + mutationFn: stopStack, + }); +} + +async function stopStack({ + id, + environmentId, +}: { + id: Stack['Id']; + environmentId?: number; +}) { + try { + const { data } = await axios.post( + buildStackUrl(id, 'stop'), + undefined, + { params: { endpointId: environmentId } } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to stop stack'); + } +} diff --git a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx b/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx index 7f7f197c6..89a91e0b0 100644 --- a/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx +++ b/app/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { RefreshCw } from 'lucide-react'; import { useRouter } from '@uirouter/react'; -import { GitStackPayload } from '@/react/common/stacks/types'; +import { GitStackPayload, StackType } from '@/react/common/stacks/types'; import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; import { baseStackWebhookUrl, @@ -234,7 +234,7 @@ export function StackRedeployGitForm({ ); const handleSubmit = useCallback(async () => { - const isSwarmStack = stack.Type === 1; + 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 @@ -356,7 +356,7 @@ export function StackRedeployGitForm({ value={formValues.AutoUpdate!} onChange={handleChangeAutoUpdate} environmentType="DOCKER" - isForcePullVisible={stack.Type !== 3} + isForcePullVisible={stack.Type !== StackType.Kubernetes} baseWebhookUrl={state.baseWebhookUrl} webhookId={state.webhookId} webhooksDocs="/user/docker/stacks/webhooks" @@ -429,25 +429,26 @@ export function StackRedeployGitForm({ isFoldable /> - {stack.Type === 1 && endpoint.apiVersion >= 1.27 && ( - -
-
- - handleChange({ Option: { Prune: value } }) - } - data-cy="stack-prune-services-switch" - /> + {stack.Type === StackType.DockerSwarm && + endpoint.apiVersion >= 1.27 && ( + +
+
+ + handleChange({ Option: { Prune: value } }) + } + data-cy="stack-prune-services-switch" + /> +
-
- - )} + + )} + HttpResponse.json({ success: true }) + ), ];