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
-
-
-
-
-
-
-
-
-
-
-
Associate to this environment
-
This feature allows you to re-associate this stack to the current environment.
-
-
-
-
-
-
-
-
-
-
-
-
+
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 }) => (
+
+
+ onChange({
+ ...values,
+ ownership: 'private',
+ })
+ }
+ >
+ Change Access Control
+
+
+ )),
+}));
+
+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 (
+
+ );
+}
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 ? (
+ handleStop()}
+ disabled={isMutating}
+ data-cy="stack-stop-btn"
+ >
+ Stop this stack
+
+ ) : (
+
+ startStackMutation.mutate(
+ { id: stackId, environmentId },
+ {
+ onError(err) {
+ notifyError(
+ 'Failure',
+ err as Error,
+ 'Unable to start stack'
+ );
+ },
+ onSuccess() {
+ notifySuccess(
+ 'Success',
+ `Stack ${stack.Name} started successfully`
+ );
+ router.stateService.reload();
+ },
+ }
+ )
+ }
+ >
+ Start this stack
+
+ )}
+
+ )}
+
+
+ handleDelete()}
+ disabled={isMutating}
+ data-cy="stack-delete-btn"
+ >
+ Delete this stack
+
+
+
+ {!!(isRegular && fileContent) && (
+
+ Create template from stack
+
+ )}
+
+ {!!(
+ 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 })
+ ),
];