mirror of
https://github.com/portainer/portainer.git
synced 2025-11-26 14:06:05 +08:00
refactor(stacks): migrate info tab to react [BE-12383] (#1415)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
44
app/react/common/stacks/queries/useDeleteStackMutation.ts
Normal file
44
app/react/common/stacks/queries/useDeleteStackMutation.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type Color =
|
||||
| 'dangerlight'
|
||||
| 'warninglight'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'none';
|
||||
type Size = 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
245
app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx
Normal file
245
app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx
Normal 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('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
159
app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx
Normal file
159
app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx
Normal 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 "Associate to this environment"
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -99,4 +99,7 @@ export const handlers = [
|
||||
message: 'Registry connection successful',
|
||||
})
|
||||
),
|
||||
http.put('/api/resource_controls/:id', () =>
|
||||
HttpResponse.json({ success: true })
|
||||
),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user