refactor(stacks): migrate info tab to react [BE-12383] (#1415)

This commit is contained in:
Chaim Lev-Ari
2025-11-25 13:17:26 +02:00
committed by GitHub
parent 0794d0f89f
commit 532575cab5
18 changed files with 1941 additions and 299 deletions

View File

@@ -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;

View File

@@ -11,125 +11,17 @@
<pr-icon icon="'list'"></pr-icon>
Stack
</uib-tab-heading>
<div style="margin-top: 10px">
<!-- stack-information -->
<div ng-if="external || orphaned">
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group">
<span class="small">
<p class="text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
<span ng-if="external">This stack was created outside of Portainer. Control over this stack is limited.</span>
<span ng-if="orphaned">This stack is orphaned. You can re-associate it with the current environment using the "Associate to this environment" feature.</span>
</p>
</span>
</div>
</div>
<!-- !stack-information -->
<!-- stack-details -->
<div>
<div class="col-sm-12 form-section-title"> Stack details </div>
<div class="form-group">
{{ stackName }}
<button
authorization="PortainerStackUpdate"
ng-if="regular && stack.Status === 2"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-success"
ng-click="startStack()"
>
<pr-icon icon="'play'"></pr-icon>
Start this stack
</button>
<button
ng-if="regular && stack.Status === 1"
authorization="PortainerStackUpdate"
ng-disabled="state.actionInProgress"
class="btn btn-xs btn-light"
ng-click="stopStack()"
>
<pr-icon icon="'stop-circle'"></pr-icon>
Stop this stack
</button>
<button authorization="PortainerStackDelete" class="btn btn-xs btn-light" ng-click="removeStack()" ng-if="!external || stack.Type == 1">
<pr-icon icon="'trash-2'"></pr-icon>
Delete this stack
</button>
<button
ng-if="regular && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
<pr-icon icon="'plus'"></pr-icon>
Create template from stack
</button>
<button
authorization="PortainerStackUpdate"
ng-if="regular && stackFileContent && !stack.FromAppTemplate && stack.GitConfig"
ng-disabled="state.actionInProgress"
ng-click="detachStackFromGit()"
button-spinner="state.actionInProgress"
class="btn btn-primary btn-xs"
>
<pr-icon icon="'arrow-right'" class-name="'mr-1'"></pr-icon>
<span ng-hide="state.actionInProgress">Detach from Git</span>
<span ng-show="state.actionInProgress">Detachment in progress...</span>
</button>
</div>
</div>
<!-- !stack-details -->
<!-- associate -->
<div ng-if="orphaned">
<div class="col-sm-12 form-section-title"> Associate to this environment </div>
<p class="small text-muted"> This feature allows you to re-associate this stack to the current environment. </p>
<form class="form-horizontal">
<por-access-control-form form-data="formValues.AccessControlData" hide-title="true"></por-access-control-form>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress"
ng-click="associateStack()"
button-spinner="state.actionInProgress"
style="margin-left: -5px"
>
<pr-icon icon="'refresh-cw'" class="!mr-1"></pr-icon>
<span ng-hide="state.actionInProgress">Associate</span>
<span ng-show="state.actionInProgress">Association in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
</div>
</div>
</form>
</div>
<!-- !associate -->
<div ng-if="!orphaned">
<stack-redeploy-git-form
ng-if="stack.GitConfig && !stack.FromAppTemplate && !state.actionInProgress"
model="stack.GitConfig"
stack="stack"
authorization="PortainerStackUpdate"
endpoint="applicationState.endpoint"
>
</stack-redeploy-git-form>
<stack-duplication-form
ng-if="stack && regular"
current-environment-id="endpoint.Id"
yaml-error="state.yamlError"
stack="stack"
original-file-content="stackFileContent"
>
</stack-duplication-form>
</div>
</div>
<stack-info-tab
stack="stack"
stack-name="stackName"
stack-file-content="stackFileContent"
is-regular="regular"
is-external="external"
is-orphaned="orphaned"
is-orphaned-running="orphanedRunning"
environment-id="endpoint.Id"
yaml-error="yamlError"
></stack-info-tab>
</uib-tab>
<!-- !tab-info -->
<!-- tab-file -->

View File

@@ -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'),
});
}

View File

@@ -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,

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -24,6 +24,7 @@ type Color =
| 'dangerlight'
| 'warninglight'
| 'warning'
| 'success'
| 'none';
type Size = 'xsmall' | 'small' | 'medium' | 'large';

View File

@@ -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 }) => (
<div data-cy="access-control-form">
<button
type="button"
onClick={() =>
onChange({
...values,
ownership: 'private',
})
}
>
Change Access Control
</button>
</div>
)),
}));
beforeEach(() => {
vi.mocked(useSwarmId).mockReturnValue({
data: undefined,
} as ReturnType<typeof useSwarmId>);
});
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<typeof useSwarmId>);
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<typeof useSwarmId>);
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<React.ComponentProps<typeof AssociateStackForm>> = {}) {
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(
<Wrapped
stackName={stackName}
environmentId={environmentId}
stackId={stackId}
isOrphanedRunning={isOrphanedRunning}
/>
);
}
function createMockStackResponse(stackId = '123') {
return {
Id: stackId,
Name: 'test-stack',
ResourceControl: {
Id: 1,
ResourceId: stackId,
Type: 6,
},
};
}

View File

@@ -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 (
<FormSection title="Associate to this environment">
<p className="small text-muted">
This feature allows you to re-associate this stack to the current
environment.
</p>
<Formik
initialValues={initialValues}
onSubmit={(values) => {
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 })}
>
<InnerForm environmentId={environmentId} />
</Formik>
</FormSection>
);
}
type FormValues = {
accessControl: AccessControlFormData;
};
function InnerForm({ environmentId }: { environmentId: EnvironmentId }) {
const { values, setFieldValue, errors, isSubmitting } =
useFormikContext<FormValues>();
return (
<Form className="form-horizontal">
<AccessControlForm
values={values.accessControl}
onChange={(newValues) => setFieldValue('accessControl', newValues)}
hideTitle
environmentId={environmentId}
errors={errors.accessControl}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
color="primary"
size="small"
isLoading={isSubmitting}
loadingText="Association in progress..."
icon={RefreshCw}
className="-ml-1.25"
data-cy="stack-associate-btn"
>
Associate
</LoadingButton>
</div>
</div>
</Form>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2">
{isRegular && (
<Authorized authorizations="PortainerStackUpdate">
{status === StackStatus.Active ? (
<Button
icon={StopCircleIcon}
color="dangerlight"
size="xsmall"
onClick={() => handleStop()}
disabled={isMutating}
data-cy="stack-stop-btn"
>
Stop this stack
</Button>
) : (
<Button
icon={PlayIcon}
color="success"
data-cy="stack-start-btn"
size="xsmall"
disabled={isMutating}
onClick={() =>
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
</Button>
)}
</Authorized>
)}
<Authorized authorizations="PortainerStackDelete">
<Button
icon={Trash2Icon}
color="dangerlight"
size="xsmall"
onClick={() => handleDelete()}
disabled={isMutating}
data-cy="stack-delete-btn"
>
Delete this stack
</Button>
</Authorized>
{!!(isRegular && fileContent) && (
<Button
as={Link}
icon={PlusIcon}
color="primary"
size="xsmall"
data-cy="stack-create-template-btn"
props={{
to: 'docker.templates.custom.new',
params: {
fileContent,
type: stack.Type,
},
}}
>
Create template from stack
</Button>
)}
{!!(
isRegular &&
fileContent &&
!stack.FromAppTemplate &&
stack.GitConfig
) && (
<Authorized authorizations="PortainerStackUpdate">
<LoadingButton
icon={ArrowRightIcon}
color="primary"
size="xsmall"
onClick={() => handleDetachFromGit()}
disabled={isMutating}
data-cy="stack-detach-git-btn"
isLoading={detachFromGitMutation.isLoading}
loadingText="Detachment in progress..."
>
Detach from Git
</LoadingButton>
</Authorized>
)}
</div>
);
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('^');
},
}
);
}
}

View File

@@ -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(() => (
<div data-cy="associate-stack-form">AssociateStackForm</div>
)),
}));
vi.mock('./StackActions', () => ({
StackActions: vi.fn(() => <div data-cy="stack-actions">StackActions</div>),
}));
vi.mock(
'@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm',
() => ({
StackDuplicationForm: vi.fn(() => (
<div data-cy="stack-duplication-form">StackDuplicationForm</div>
)),
})
);
vi.mock(
'@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm',
() => ({
StackRedeployGitForm: vi.fn(() => (
<div data-cy="stack-redeploy-git-form">StackRedeployGitForm</div>
)),
})
);
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<React.ComponentProps<typeof StackInfoTab>> = {}) {
// 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(
<Wrapped
stack={stack}
stackName={stackName}
stackFileContent={stackFileContent}
isRegular={isRegular}
isExternal={isExternal}
isOrphaned={isOrphaned}
isOrphanedRunning={isOrphanedRunning}
environmentId={environmentId}
yamlError={yamlError}
/>
);
}
function createMockStack(overrides?: Partial<Stack>): 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,
};
}

View File

@@ -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 (
<>
<ExternalOrphanedWarning
isExternal={isExternal}
isOrphaned={isOrphaned || isOrphanedRunning}
/>
<FormSection title="Stack details">
<div className="form-group">
{stackName}
{stack && (
<div className="inline-flex ml-3">
<StackActions
stack={stack}
fileContent={stackFileContent}
isRegular={isRegular}
environmentId={environmentId}
isExternal={isExternal}
/>
</div>
)}
</div>
</FormSection>
{stack && (
<>
{isOrphaned ? (
<AssociateStackForm
stackName={stackName}
environmentId={environmentId}
isOrphanedRunning={isOrphanedRunning}
stackId={stack.Id}
/>
) : (
<>
{stack.GitConfig && !stack.FromAppTemplate && (
<Authorized authorizations="PortainerStackUpdate">
<StackRedeployGitFormWrapper
stack={stack}
environmentId={environmentId}
/>
</Authorized>
)}
{isRegular && (
<StackDuplicationForm
yamlError={yamlError}
currentEnvironmentId={environmentId}
originalFileContent={stackFileContent || ''}
stack={stack}
/>
)}
</>
)}
</>
)}
</>
);
}
function ExternalOrphanedWarning({
isExternal,
isOrphaned,
}: {
isExternal: boolean;
isOrphaned: boolean;
}) {
if (!isExternal && !isOrphaned) return null;
return (
<FormSection title="Information">
<div className="form-group">
<span className="small">
<p className="text-muted flex items-start gap-1">
<Icon icon={AlertTriangle} mode="warning" className="!mr-0" />
{isExternal && (
<span>
This stack was created outside of Portainer. Control over this
stack is limited.
</span>
)}
{isOrphaned && (
<span>
This stack is orphaned. You can re-associate it with the current
environment using the &quot;Associate to this environment&quot;
feature.
</span>
)}
</p>
</span>
</div>
</FormSection>
);
}
function StackRedeployGitFormWrapper({
stack,
environmentId,
}: {
stack: Stack;
environmentId: EnvironmentId;
}) {
const apiVersion = useApiVersion(environmentId);
if (!stack.GitConfig) {
return null;
}
return (
<StackRedeployGitForm
model={stack.GitConfig}
endpoint={{
Id: environmentId,
apiVersion,
}}
stack={stack}
/>
);
}

View File

@@ -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<Stack>);
}),
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<Stack>)
),
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<Stack>);
}),
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<Stack>);
}),
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<typeof vi.spyOn>;
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<Stack>)
)
);
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<Stack>)
),
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<Stack>);
}),
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<Stack>;
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<Stack>)
),
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<Stack>)
),
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();
});
});
});

View File

@@ -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<Stack>(
buildStackUrl(stackId, 'associate'),
{},
{
params: {
endpointId: environmentId,
orphanedRunning: isOrphanedRunning ?? false,
swarmId,
},
}
);
return data;
}

View File

@@ -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<Stack>(
buildStackUrl(id, 'start'),
undefined,
{ params: { endpointId: environmentId } }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to start stack');
}
}

View File

@@ -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<Stack>(
buildStackUrl(id, 'stop'),
undefined,
{ params: { endpointId: environmentId } }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to stop stack');
}
}

View File

@@ -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 && (
<FormSection title="Options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="prune"
checked={formValues.Option.Prune || false}
tooltip="Prune services that are no longer referenced."
labelClass="col-sm-3 col-lg-2"
label="Prune services"
onChange={(value: boolean) =>
handleChange({ Option: { Prune: value } })
}
data-cy="stack-prune-services-switch"
/>
{stack.Type === StackType.DockerSwarm &&
endpoint.apiVersion >= 1.27 && (
<FormSection title="Options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="prune"
checked={formValues.Option.Prune || false}
tooltip="Prune services that are no longer referenced."
labelClass="col-sm-3 col-lg-2"
label="Prune services"
onChange={(value: boolean) =>
handleChange({ Option: { Prune: value } })
}
data-cy="stack-prune-services-switch"
/>
</div>
</div>
</div>
</FormSection>
)}
</FormSection>
)}
<FormSection title="Actions">
<LoadingButton

View File

@@ -99,4 +99,7 @@ export const handlers = [
message: 'Registry connection successful',
})
),
http.put('/api/resource_controls/:id', () =>
HttpResponse.json({ success: true })
),
];