mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 23:07:42 -02:30
@@ -16,6 +16,11 @@ This is a list of high-level changes for each release of AWX. A full list of com
|
|||||||
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
|
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
|
||||||
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
|
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
|
||||||
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
|
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
|
||||||
|
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
|
||||||
|
|
||||||
|
# 17.1.0 (March 9th, 2021)
|
||||||
|
- Addressed a security issue in AWX (CVE-2021-20253)
|
||||||
|
- Fixed a bug permissions error related to redis in K8S-based deployments: https://github.com/ansible/awx/issues/9401
|
||||||
|
|
||||||
# 17.0.1 (January 26, 2021)
|
# 17.0.1 (January 26, 2021)
|
||||||
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ If you're attempting to migrate an older Docker-based AWX installation, see: [Mi
|
|||||||
|
|
||||||
Starting in version 18.0, the [AWX Operator](https://github.com/ansible/awx-operator) is the preferred way to install AWX.
|
Starting in version 18.0, the [AWX Operator](https://github.com/ansible/awx-operator) is the preferred way to install AWX.
|
||||||
|
|
||||||
|
AWX can also alternatively be installed and [run in Docker](./tools/docker-compose/README.md), but this install path is only recommended for development/test-oriented deployments, and has no official published release.
|
||||||
|
|
||||||
### Quickstart with minikube
|
### Quickstart with minikube
|
||||||
|
|
||||||
If you don't have an existing OpenShift or Kubernetes cluster, minikube is a fast and easy way to get up and running.
|
If you don't have an existing OpenShift or Kubernetes cluster, minikube is a fast and easy way to get up and running.
|
||||||
|
|||||||
@@ -21,13 +21,6 @@ from split_settings.tools import optional, include
|
|||||||
# Load default settings.
|
# Load default settings.
|
||||||
from .defaults import * # NOQA
|
from .defaults import * # NOQA
|
||||||
|
|
||||||
if "pytest" in sys.modules:
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
|
||||||
'LOCATION': 'unique-{}'.format(str(uuid.uuid4())),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# awx-manage shell_plus --notebook
|
# awx-manage shell_plus --notebook
|
||||||
NOTEBOOK_ARGUMENTS = [
|
NOTEBOOK_ARGUMENTS = [
|
||||||
@@ -53,6 +46,10 @@ LOGGING['loggers']['awx.isolated.manager.playbooks']['propagate'] = True # noqa
|
|||||||
|
|
||||||
# celery is annoyingly loud when docker containers start
|
# celery is annoyingly loud when docker containers start
|
||||||
LOGGING['loggers'].pop('celery', None) # noqa
|
LOGGING['loggers'].pop('celery', None) # noqa
|
||||||
|
# avoid awx.main.dispatch WARNING-level scaling worker up/down messages
|
||||||
|
LOGGING['loggers']['awx.main.dispatch']['level'] = 'ERROR' # noqa
|
||||||
|
# suppress the spamminess of the awx.main.scheduler and .tasks loggers
|
||||||
|
LOGGING['loggers']['awx']['level'] = 'INFO' # noqa
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['*']
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
@@ -166,6 +163,27 @@ except ImportError:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
|
||||||
|
# commented out, Django will create the test_awx-dev database in PostgreSQL to
|
||||||
|
# run unit tests.
|
||||||
|
if "pytest" in sys.modules:
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
'LOCATION': 'unique-{}'.format(str(uuid.uuid4())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'), # noqa
|
||||||
|
'TEST': {
|
||||||
|
# Test database cannot be :memory: for inventory tests.
|
||||||
|
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'), # noqa
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
CELERYBEAT_SCHEDULE.update({ # noqa
|
CELERYBEAT_SCHEDULE.update({ # noqa
|
||||||
'isolated_heartbeat': {
|
'isolated_heartbeat': {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
DataListItem,
|
DataListItem,
|
||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
@@ -9,6 +10,14 @@ import {
|
|||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import DataListCell from '../DataListCell';
|
import DataListCell from '../DataListCell';
|
||||||
|
|
||||||
|
const Label = styled.label`
|
||||||
|
${({ isDisabled }) =>
|
||||||
|
isDisabled &&
|
||||||
|
`
|
||||||
|
opacity: 0.5;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
const CheckboxListItem = ({
|
const CheckboxListItem = ({
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
isRadio = false,
|
isRadio = false,
|
||||||
@@ -32,7 +41,7 @@ const CheckboxListItem = ({
|
|||||||
aria-label={`check-action-item-${itemId}`}
|
aria-label={`check-action-item-${itemId}`}
|
||||||
aria-labelledby={`check-action-item-${itemId}`}
|
aria-labelledby={`check-action-item-${itemId}`}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
disabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
id={`selected-${itemId}`}
|
id={`selected-${itemId}`}
|
||||||
isChecked={isSelected}
|
isChecked={isSelected}
|
||||||
name={name}
|
name={name}
|
||||||
@@ -42,13 +51,14 @@ const CheckboxListItem = ({
|
|||||||
<DataListItemCells
|
<DataListItemCells
|
||||||
dataListCells={[
|
dataListCells={[
|
||||||
<DataListCell key="name">
|
<DataListCell key="name">
|
||||||
<label
|
<Label
|
||||||
id={`check-action-item-${itemId}`}
|
id={`check-action-item-${itemId}`}
|
||||||
htmlFor={`selected-${itemId}`}
|
htmlFor={`selected-${itemId}`}
|
||||||
className="check-action-item"
|
className="check-action-item"
|
||||||
|
isDisabled={isDisabled}
|
||||||
>
|
>
|
||||||
<b>{label}</b>
|
<b>{label}</b>
|
||||||
</label>
|
</Label>
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function CopyButton({
|
|||||||
onCopyFinish,
|
onCopyFinish,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
i18n,
|
i18n,
|
||||||
|
ouiaId,
|
||||||
}) {
|
}) {
|
||||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||||
copyItem
|
copyItem
|
||||||
@@ -35,6 +36,7 @@ function CopyButton({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
id={id}
|
id={id}
|
||||||
|
ouiaId={ouiaId}
|
||||||
isDisabled={isLoading || isDisabled}
|
isDisabled={isLoading || isDisabled}
|
||||||
aria-label={i18n._(t`Copy`)}
|
aria-label={i18n._(t`Copy`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -62,10 +64,12 @@ CopyButton.propTypes = {
|
|||||||
onCopyFinish: PropTypes.func.isRequired,
|
onCopyFinish: PropTypes.func.isRequired,
|
||||||
errorMessage: PropTypes.string.isRequired,
|
errorMessage: PropTypes.string.isRequired,
|
||||||
isDisabled: PropTypes.bool,
|
isDisabled: PropTypes.bool,
|
||||||
|
ouiaId: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
CopyButton.defaultProps = {
|
CopyButton.defaultProps = {
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
ouiaId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(CopyButton);
|
export default withI18n()(CopyButton);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function ScheduleForm({
|
|||||||
const {
|
const {
|
||||||
request: loadScheduleData,
|
request: loadScheduleData,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
contentLoading,
|
isLoading: contentLoading,
|
||||||
result: { zoneOptions, credentials },
|
result: { zoneOptions, credentials },
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ describe('<ScheduleForm />', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
});
|
});
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertActionCloseButton,
|
||||||
|
AlertGroup,
|
||||||
|
Card,
|
||||||
|
PageSection,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
import { NotificationTemplatesAPI } from '../../../api';
|
import { NotificationTemplatesAPI } from '../../../api';
|
||||||
import PaginatedTable, {
|
import PaginatedTable, {
|
||||||
HeaderRow,
|
HeaderRow,
|
||||||
@@ -29,6 +35,7 @@ const QS_CONFIG = getQSConfig('notification-templates', {
|
|||||||
function NotificationTemplatesList({ i18n }) {
|
function NotificationTemplatesList({ i18n }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
const [testToasts, setTestToasts] = useState([]);
|
||||||
|
|
||||||
const addUrl = `${match.url}/add`;
|
const addUrl = `${match.url}/add`;
|
||||||
|
|
||||||
@@ -102,6 +109,16 @@ function NotificationTemplatesList({ i18n }) {
|
|||||||
setSelected([]);
|
setSelected([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addTestToast = useCallback(notification => {
|
||||||
|
setTestToasts(oldToasts => [...oldToasts, notification]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeTestToast = notificationId => {
|
||||||
|
setTestToasts(oldToasts =>
|
||||||
|
oldToasts.filter(toast => toast.id !== notificationId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const canAdd = actions && actions.POST;
|
const canAdd = actions && actions.POST;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +202,7 @@ function NotificationTemplatesList({ i18n }) {
|
|||||||
}
|
}
|
||||||
renderRow={(template, index) => (
|
renderRow={(template, index) => (
|
||||||
<NotificationTemplateListItem
|
<NotificationTemplateListItem
|
||||||
|
onAddToast={addTestToast}
|
||||||
key={template.id}
|
key={template.id}
|
||||||
fetchTemplates={fetchTemplates}
|
fetchTemplates={fetchTemplates}
|
||||||
template={template}
|
template={template}
|
||||||
@@ -209,6 +227,39 @@ function NotificationTemplatesList({ i18n }) {
|
|||||||
{i18n._(t`Failed to delete one or more notification template.`)}
|
{i18n._(t`Failed to delete one or more notification template.`)}
|
||||||
<ErrorDetail error={deletionError} />
|
<ErrorDetail error={deletionError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
|
<AlertGroup ouiaId="notification-template-alerts" isToast>
|
||||||
|
{testToasts
|
||||||
|
.filter(notification => notification.status !== 'pending')
|
||||||
|
.map(notification => (
|
||||||
|
<Alert
|
||||||
|
actionClose={
|
||||||
|
<AlertActionCloseButton
|
||||||
|
onClose={() => removeTestToast(notification.id)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onTimeout={() => removeTestToast(notification.id)}
|
||||||
|
timeout={notification.status !== 'failed'}
|
||||||
|
title={notification.summary_fields.notification_template.name}
|
||||||
|
variant={notification.status === 'failed' ? 'danger' : 'success'}
|
||||||
|
key={`notification-template-alert-${notification.id}`}
|
||||||
|
ouiaId={`notification-template-alert-${notification.id}`}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{notification.status === 'successful' && (
|
||||||
|
<p>{i18n._(t`Notification sent successfully`)}</p>
|
||||||
|
)}
|
||||||
|
{notification.status === 'failed' &&
|
||||||
|
notification?.error === 'timed out' && (
|
||||||
|
<p>{i18n._(t`Notification timed out`)}</p>
|
||||||
|
)}
|
||||||
|
{notification.status === 'failed' &&
|
||||||
|
notification?.error !== 'timed out' && (
|
||||||
|
<p>{notification.error}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</AlertGroup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { OrganizationsAPI } from '../../../api';
|
import {
|
||||||
|
NotificationsAPI,
|
||||||
|
NotificationTemplatesAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
} from '../../../api';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import NotificationTemplateList from './NotificationTemplateList';
|
import NotificationTemplateList from './NotificationTemplateList';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
const mockTemplates = {
|
const mockTemplates = {
|
||||||
data: {
|
data: {
|
||||||
count: 3,
|
count: 3,
|
||||||
@@ -197,6 +203,43 @@ describe('<NotificationTemplateList />', () => {
|
|||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show toast after test resolves', async () => {
|
||||||
|
NotificationTemplatesAPI.test.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
notification: 9182,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
NotificationsAPI.readDetail.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: 9182,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'There was an error with the notification',
|
||||||
|
summary_fields: {
|
||||||
|
notification_template: {
|
||||||
|
name: 'foobar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<NotificationTemplateList />);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('Alert').length).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Test Notification"]')
|
||||||
|
.at(0)
|
||||||
|
.simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('Alert').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should hide add button (rbac)', async () => {
|
test('should hide add button (rbac)', async () => {
|
||||||
OrganizationsAPI.readOptions.mockResolvedValue({
|
OrganizationsAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import { timeOfDay } from '../../../util/dates';
|
|||||||
import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api';
|
import { NotificationTemplatesAPI, NotificationsAPI } from '../../../api';
|
||||||
import StatusLabel from '../../../components/StatusLabel';
|
import StatusLabel from '../../../components/StatusLabel';
|
||||||
import CopyButton from '../../../components/CopyButton';
|
import CopyButton from '../../../components/CopyButton';
|
||||||
import useRequest from '../../../util/useRequest';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { NOTIFICATION_TYPES } from '../constants';
|
import { NOTIFICATION_TYPES } from '../constants';
|
||||||
|
|
||||||
const NUM_RETRIES = 25;
|
const NUM_RETRIES = 25;
|
||||||
const RETRY_TIMEOUT = 5000;
|
const RETRY_TIMEOUT = 5000;
|
||||||
|
|
||||||
function NotificationTemplateListItem({
|
function NotificationTemplateListItem({
|
||||||
|
onAddToast,
|
||||||
template,
|
template,
|
||||||
detailUrl,
|
detailUrl,
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
@@ -66,6 +69,7 @@ function NotificationTemplateListItem({
|
|||||||
notificationId
|
notificationId
|
||||||
);
|
);
|
||||||
if (notification.status !== 'pending') {
|
if (notification.status !== 'pending') {
|
||||||
|
onAddToast(notification);
|
||||||
setStatus(notification.status);
|
setStatus(notification.status);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,9 +80,11 @@ function NotificationTemplateListItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(pollForStatusChange, RETRY_TIMEOUT);
|
setTimeout(pollForStatusChange, RETRY_TIMEOUT);
|
||||||
}, [template.id])
|
}, [template.id, onAddToast])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { error: sendTestError, dismissError } = useDismissableError(error);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -88,65 +94,81 @@ function NotificationTemplateListItem({
|
|||||||
const labelId = `template-name-${template.id}`;
|
const labelId = `template-name-${template.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr id={`notification-template-row-${template.id}`}>
|
<>
|
||||||
<Td
|
<Tr id={`notification-template-row-${template.id}`}>
|
||||||
select={{
|
<Td
|
||||||
rowIndex,
|
select={{
|
||||||
isSelected,
|
rowIndex,
|
||||||
onSelect,
|
isSelected,
|
||||||
}}
|
onSelect,
|
||||||
dataLabel={i18n._(t`Selected`)}
|
}}
|
||||||
/>
|
dataLabel={i18n._(t`Selected`)}
|
||||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
/>
|
||||||
<Link to={`${detailUrl}`}>
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
<b>{template.name}</b>
|
<Link to={`${detailUrl}`}>
|
||||||
</Link>
|
<b>{template.name}</b>
|
||||||
</Td>
|
</Link>
|
||||||
<Td dataLabel={i18n._(t`Status`)}>
|
</Td>
|
||||||
{status && <StatusLabel status={status} />}
|
<Td dataLabel={i18n._(t`Status`)}>
|
||||||
</Td>
|
{status && <StatusLabel status={status} />}
|
||||||
<Td dataLabel={i18n._(t`Type`)}>
|
</Td>
|
||||||
{NOTIFICATION_TYPES[template.notification_type] ||
|
<Td dataLabel={i18n._(t`Type`)}>
|
||||||
template.notification_type}
|
{NOTIFICATION_TYPES[template.notification_type] ||
|
||||||
</Td>
|
template.notification_type}
|
||||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
</Td>
|
||||||
<ActionItem visible tooltip={i18n._(t`Test notification`)}>
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
<Button
|
<ActionItem visible tooltip={i18n._(t`Test notification`)}>
|
||||||
aria-label={i18n._(t`Test Notification`)}
|
<Button
|
||||||
variant="plain"
|
ouiaId={`notification-test-button-${template.id}`}
|
||||||
onClick={sendTestNotification}
|
aria-label={i18n._(t`Test Notification`)}
|
||||||
isDisabled={isLoading || status === 'running'}
|
variant="plain"
|
||||||
|
onClick={sendTestNotification}
|
||||||
|
isDisabled={isLoading || status === 'running'}
|
||||||
|
>
|
||||||
|
<BellIcon />
|
||||||
|
</Button>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.summary_fields.user_capabilities.edit}
|
||||||
|
tooltip={i18n._(t`Edit`)}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
<Button
|
||||||
</Button>
|
ouiaId={`notification-edit-button-${template.id}`}
|
||||||
</ActionItem>
|
aria-label={i18n._(t`Edit Notification Template`)}
|
||||||
<ActionItem
|
variant="plain"
|
||||||
visible={template.summary_fields.user_capabilities.edit}
|
component={Link}
|
||||||
tooltip={i18n._(t`Edit`)}
|
to={`/notification_templates/${template.id}/edit`}
|
||||||
>
|
>
|
||||||
<Button
|
<PencilAltIcon />
|
||||||
aria-label={i18n._(t`Edit Notification Template`)}
|
</Button>
|
||||||
variant="plain"
|
</ActionItem>
|
||||||
component={Link}
|
<ActionItem
|
||||||
to={`/notification_templates/${template.id}/edit`}
|
visible={template.summary_fields.user_capabilities.copy}
|
||||||
|
tooltip={i18n._(t`Copy Notification Template`)}
|
||||||
>
|
>
|
||||||
<PencilAltIcon />
|
<CopyButton
|
||||||
</Button>
|
ouiaId={`notification-copy-button-${template.id}`}
|
||||||
</ActionItem>
|
copyItem={copyTemplate}
|
||||||
<ActionItem
|
isCopyDisabled={isCopyDisabled}
|
||||||
visible={template.summary_fields.user_capabilities.copy}
|
onCopyStart={handleCopyStart}
|
||||||
tooltip={i18n._(t`Copy Notification Template`)}
|
onCopyFinish={handleCopyFinish}
|
||||||
|
errorMessage={i18n._(t`Failed to copy template.`)}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
</Tr>
|
||||||
|
{sendTestError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={dismissError}
|
||||||
>
|
>
|
||||||
<CopyButton
|
{i18n._(t`Failed to send test notification.`)}
|
||||||
copyItem={copyTemplate}
|
<ErrorDetail error={sendTestError} />
|
||||||
isCopyDisabled={isCopyDisabled}
|
</AlertModal>
|
||||||
onCopyStart={handleCopyStart}
|
)}
|
||||||
onCopyFinish={handleCopyFinish}
|
</>
|
||||||
errorMessage={i18n._(t`Failed to copy template.`)}
|
|
||||||
/>
|
|
||||||
</ActionItem>
|
|
||||||
</ActionsTd>
|
|
||||||
</Tr>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function JobTemplateEdit({ template }) {
|
|||||||
webhook_credential,
|
webhook_credential,
|
||||||
webhook_key,
|
webhook_key,
|
||||||
webhook_url,
|
webhook_url,
|
||||||
|
execution_environment,
|
||||||
...remainingValues
|
...remainingValues
|
||||||
} = values;
|
} = values;
|
||||||
|
|
||||||
@@ -56,11 +57,9 @@ function JobTemplateEdit({ template }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
remainingValues.project = values.project.id;
|
remainingValues.project = values.project.id;
|
||||||
remainingValues.webhook_credential = webhook_credential?.id || null;
|
remainingValues.webhook_credential = webhook_credential?.id || null;
|
||||||
|
remainingValues.execution_environment = execution_environment?.id || null;
|
||||||
try {
|
try {
|
||||||
await JobTemplatesAPI.update(template.id, {
|
await JobTemplatesAPI.update(template.id, remainingValues);
|
||||||
...remainingValues,
|
|
||||||
execution_environment: values.execution_environment?.id,
|
|
||||||
});
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
submitLabels(labels, template?.organization),
|
submitLabels(labels, template?.organization),
|
||||||
submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
LabelsAPI,
|
LabelsAPI,
|
||||||
ProjectsAPI,
|
ProjectsAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
|
ExecutionEnvironmentsAPI,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import JobTemplateEdit from './JobTemplateEdit';
|
import JobTemplateEdit from './JobTemplateEdit';
|
||||||
|
|
||||||
@@ -49,6 +50,12 @@ const mockJobTemplate = {
|
|||||||
scm_branch: '',
|
scm_branch: '',
|
||||||
skip_tags: '',
|
skip_tags: '',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
|
execution_environment: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Default EE',
|
||||||
|
description: '',
|
||||||
|
image: 'quay.io/ansible/awx-ee',
|
||||||
|
},
|
||||||
user_capabilities: {
|
user_capabilities: {
|
||||||
edit: true,
|
edit: true,
|
||||||
},
|
},
|
||||||
@@ -81,6 +88,7 @@ const mockJobTemplate = {
|
|||||||
related: {
|
related: {
|
||||||
webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
|
webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
|
||||||
},
|
},
|
||||||
|
execution_environment: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockRelatedCredentials = {
|
const mockRelatedCredentials = {
|
||||||
@@ -176,6 +184,15 @@ const mockInstanceGroups = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockExecutionEnvironment = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Default EE',
|
||||||
|
description: '',
|
||||||
|
image: 'quay.io/ansible/awx-ee',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
JobTemplatesAPI.readCredentials.mockResolvedValue({
|
JobTemplatesAPI.readCredentials.mockResolvedValue({
|
||||||
data: mockRelatedCredentials,
|
data: mockRelatedCredentials,
|
||||||
});
|
});
|
||||||
@@ -197,6 +214,10 @@ CredentialsAPI.read.mockResolvedValue({
|
|||||||
});
|
});
|
||||||
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
|
CredentialTypesAPI.loadAllTypes.mockResolvedValue([]);
|
||||||
|
|
||||||
|
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||||
|
data: mockExecutionEnvironment,
|
||||||
|
});
|
||||||
|
|
||||||
describe('<JobTemplateEdit />', () => {
|
describe('<JobTemplateEdit />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
|
LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
|
||||||
@@ -266,6 +287,8 @@ describe('<JobTemplateEdit />', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
organization: 1,
|
organization: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -277,6 +300,7 @@ describe('<JobTemplateEdit />', () => {
|
|||||||
...mockJobTemplate,
|
...mockJobTemplate,
|
||||||
project: mockJobTemplate.project,
|
project: mockJobTemplate.project,
|
||||||
...updatedTemplateData,
|
...updatedTemplateData,
|
||||||
|
execution_environment: null,
|
||||||
};
|
};
|
||||||
delete expected.summary_fields;
|
delete expected.summary_fields;
|
||||||
delete expected.id;
|
delete expected.id;
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ function WorkflowJobTemplateEdit({ template }) {
|
|||||||
organization,
|
organization,
|
||||||
webhook_credential,
|
webhook_credential,
|
||||||
webhook_key,
|
webhook_key,
|
||||||
|
execution_environment,
|
||||||
...templatePayload
|
...templatePayload
|
||||||
} = values;
|
} = values;
|
||||||
templatePayload.inventory = inventory?.id || null;
|
templatePayload.inventory = inventory?.id || null;
|
||||||
templatePayload.organization = organization?.id || null;
|
templatePayload.organization = organization?.id || null;
|
||||||
templatePayload.webhook_credential = webhook_credential?.id || null;
|
templatePayload.webhook_credential = webhook_credential?.id || null;
|
||||||
|
templatePayload.execution_environment = execution_environment?.id || null;
|
||||||
|
|
||||||
const formOrgId =
|
const formOrgId =
|
||||||
organization?.id || inventory?.summary_fields?.organization.id || null;
|
organization?.id || inventory?.summary_fields?.organization.id || null;
|
||||||
@@ -29,10 +31,7 @@ function WorkflowJobTemplateEdit({ template }) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
await submitLabels(labels, formOrgId, template.organization)
|
await submitLabels(labels, formOrgId, template.organization)
|
||||||
);
|
);
|
||||||
await WorkflowJobTemplatesAPI.update(template.id, {
|
await WorkflowJobTemplatesAPI.update(template.id, templatePayload);
|
||||||
...templatePayload,
|
|
||||||
execution_environment: values.execution_environment?.id,
|
|
||||||
});
|
|
||||||
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormSubmitError(err);
|
setFormSubmitError(err);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
LabelsAPI,
|
LabelsAPI,
|
||||||
|
ExecutionEnvironmentsAPI,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
|
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
|
||||||
@@ -14,12 +15,19 @@ jest.mock('../../../api/models/WorkflowJobTemplates');
|
|||||||
jest.mock('../../../api/models/Labels');
|
jest.mock('../../../api/models/Labels');
|
||||||
jest.mock('../../../api/models/Organizations');
|
jest.mock('../../../api/models/Organizations');
|
||||||
jest.mock('../../../api/models/Inventories');
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/ExecutionEnvironments');
|
||||||
|
|
||||||
const mockTemplate = {
|
const mockTemplate = {
|
||||||
id: 6,
|
id: 6,
|
||||||
name: 'Foo',
|
name: 'Foo',
|
||||||
description: 'Foo description',
|
description: 'Foo description',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
|
execution_environment: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Default EE',
|
||||||
|
description: '',
|
||||||
|
image: 'quay.io/ansible/awx-ee',
|
||||||
|
},
|
||||||
inventory: { id: 1, name: 'Inventory 1' },
|
inventory: { id: 1, name: 'Inventory 1' },
|
||||||
organization: { id: 1, name: 'Organization 1' },
|
organization: { id: 1, name: 'Organization 1' },
|
||||||
labels: {
|
labels: {
|
||||||
@@ -32,7 +40,18 @@ const mockTemplate = {
|
|||||||
scm_branch: 'devel',
|
scm_branch: 'devel',
|
||||||
limit: '5000',
|
limit: '5000',
|
||||||
variables: '---',
|
variables: '---',
|
||||||
|
execution_environment: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockExecutionEnvironment = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Default EE',
|
||||||
|
description: '',
|
||||||
|
image: 'quay.io/ansible/awx-ee',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe('<WorkflowJobTemplateEdit/>', () => {
|
describe('<WorkflowJobTemplateEdit/>', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let history;
|
let history;
|
||||||
@@ -48,6 +67,9 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
|
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
|
||||||
|
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||||
|
data: mockExecutionEnvironment,
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
history = createMemoryHistory({
|
history = createMemoryHistory({
|
||||||
@@ -100,6 +122,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
|||||||
.find('LabelSelect')
|
.find('LabelSelect')
|
||||||
.find('SelectToggle')
|
.find('SelectToggle')
|
||||||
.simulate('click');
|
.simulate('click');
|
||||||
|
wrapper.find('ExecutionEnvironmentLookup').invoke('onChange')(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
@@ -142,6 +165,7 @@ describe('<WorkflowJobTemplateEdit/>', () => {
|
|||||||
ask_limit_on_launch: false,
|
ask_limit_on_launch: false,
|
||||||
ask_scm_branch_on_launch: false,
|
ask_scm_branch_on_launch: false,
|
||||||
ask_variables_on_launch: false,
|
ask_variables_on_launch: false,
|
||||||
|
execution_environment: null,
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
|
await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ const mockJobTemplate = {
|
|||||||
},
|
},
|
||||||
related: { webhook_receiver: '' },
|
related: { webhook_receiver: '' },
|
||||||
inventory: 1,
|
inventory: 1,
|
||||||
|
project: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('NodeModal', () => {
|
describe('NodeModal', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { func, shape } from 'prop-types';
|
import { func, shape } from 'prop-types';
|
||||||
|
import { Tooltip } from '@patternfly/react-core';
|
||||||
import { JobTemplatesAPI } from '../../../../../../api';
|
import { JobTemplatesAPI } from '../../../../../../api';
|
||||||
import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
|
||||||
import useRequest from '../../../../../../util/useRequest';
|
import useRequest from '../../../../../../util/useRequest';
|
||||||
@@ -56,26 +57,56 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) {
|
|||||||
fetchJobTemplates();
|
fetchJobTemplates();
|
||||||
}, [fetchJobTemplates]);
|
}, [fetchJobTemplates]);
|
||||||
|
|
||||||
|
const onSelectRow = row => {
|
||||||
|
if (
|
||||||
|
row.project &&
|
||||||
|
row.project !== null &&
|
||||||
|
((row.inventory && row.inventory !== null) || row.ask_inventory_on_launch)
|
||||||
|
) {
|
||||||
|
onUpdateNodeResource(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
contentError={error}
|
contentError={error}
|
||||||
hasContentLoading={isLoading}
|
hasContentLoading={isLoading}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
items={jobTemplates}
|
items={jobTemplates}
|
||||||
onRowClick={row => onUpdateNodeResource(row)}
|
onRowClick={row => onSelectRow(row)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
renderItem={item => (
|
renderItem={item => {
|
||||||
<CheckboxListItem
|
const isDisabled =
|
||||||
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
!item.project ||
|
||||||
itemId={item.id}
|
item.project === null ||
|
||||||
key={item.id}
|
((!item.inventory || item.inventory === null) &&
|
||||||
name={item.name}
|
!item.ask_inventory_on_launch);
|
||||||
label={item.name}
|
const listItem = (
|
||||||
onSelect={() => onUpdateNodeResource(item)}
|
<CheckboxListItem
|
||||||
onDeselect={() => onUpdateNodeResource(null)}
|
isDisabled={isDisabled}
|
||||||
isRadio
|
isSelected={!!(nodeResource && nodeResource.id === item.id)}
|
||||||
/>
|
itemId={item.id}
|
||||||
)}
|
key={`${item.id}-listItem`}
|
||||||
|
name={item.name}
|
||||||
|
label={item.name}
|
||||||
|
onSelect={() => onSelectRow(item)}
|
||||||
|
onDeselect={() => onUpdateNodeResource(null)}
|
||||||
|
isRadio
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return isDisabled ? (
|
||||||
|
<Tooltip
|
||||||
|
key={`${item.id}-tooltip`}
|
||||||
|
content={i18n._(
|
||||||
|
t`Job Templates with a missing inventory or project cannot be selected when creating or editing nodes`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{listItem}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
listItem
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||||
showPageSizeOptions={false}
|
showPageSizeOptions={false}
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const onUpdateNodeResource = jest.fn();
|
|||||||
describe('JobTemplatesList', () => {
|
describe('JobTemplatesList', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
test('Row selected when nodeResource id matches row id and clicking new row makes expected callback', async () => {
|
||||||
@@ -28,12 +29,16 @@ describe('JobTemplatesList', () => {
|
|||||||
name: 'Test Job Template',
|
name: 'Test Job Template',
|
||||||
type: 'job_template',
|
type: 'job_template',
|
||||||
url: '/api/v2/job_templates/1',
|
url: '/api/v2/job_templates/1',
|
||||||
|
inventory: 1,
|
||||||
|
project: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Test Job Template 2',
|
name: 'Test Job Template 2',
|
||||||
type: 'job_template',
|
type: 'job_template',
|
||||||
url: '/api/v2/job_templates/2',
|
url: '/api/v2/job_templates/2',
|
||||||
|
inventory: 1,
|
||||||
|
project: 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -60,10 +65,18 @@ describe('JobTemplatesList', () => {
|
|||||||
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||||
.isSelected
|
.isSelected
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||||
|
.isDisabled
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
||||||
.isSelected
|
.isSelected
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
||||||
|
.isDisabled
|
||||||
|
).toBe(false);
|
||||||
wrapper
|
wrapper
|
||||||
.find('CheckboxListItem[name="Test Job Template 2"]')
|
.find('CheckboxListItem[name="Test Job Template 2"]')
|
||||||
.simulate('click');
|
.simulate('click');
|
||||||
@@ -72,8 +85,75 @@ describe('JobTemplatesList', () => {
|
|||||||
name: 'Test Job Template 2',
|
name: 'Test Job Template 2',
|
||||||
type: 'job_template',
|
type: 'job_template',
|
||||||
url: '/api/v2/job_templates/2',
|
url: '/api/v2/job_templates/2',
|
||||||
|
inventory: 1,
|
||||||
|
project: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
test('Row disabled when job template missing inventory or project', async () => {
|
||||||
|
JobTemplatesAPI.read.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Job Template',
|
||||||
|
type: 'job_template',
|
||||||
|
url: '/api/v2/job_templates/1',
|
||||||
|
inventory: 1,
|
||||||
|
project: null,
|
||||||
|
ask_inventory_on_launch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Test Job Template 2',
|
||||||
|
type: 'job_template',
|
||||||
|
url: '/api/v2/job_templates/2',
|
||||||
|
inventory: null,
|
||||||
|
project: 2,
|
||||||
|
ask_inventory_on_launch: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
JobTemplatesAPI.readOptions.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
POST: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<JobTemplatesList
|
||||||
|
nodeResource={nodeResource}
|
||||||
|
onUpdateNodeResource={onUpdateNodeResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||||
|
.isSelected
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
|
||||||
|
.isDisabled
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
||||||
|
.isSelected
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
|
||||||
|
.isDisabled
|
||||||
|
).toBe(true);
|
||||||
|
wrapper
|
||||||
|
.find('CheckboxListItem[name="Test Job Template 2"]')
|
||||||
|
.simulate('click');
|
||||||
|
expect(onUpdateNodeResource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
test('Error shown when read() request errors', async () => {
|
test('Error shown when read() request errors', async () => {
|
||||||
JobTemplatesAPI.read.mockRejectedValue(new Error());
|
JobTemplatesAPI.read.mockRejectedValue(new Error());
|
||||||
JobTemplatesAPI.readOptions.mockResolvedValue({
|
JobTemplatesAPI.readOptions.mockResolvedValue({
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ const getAggregatedCredentials = (
|
|||||||
templateDefaultCred.credential_type === overrideCred.credential_type
|
templateDefaultCred.credential_type === overrideCred.credential_type
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
(!templateDefaultCred.vault_id && !overrideCred.inputs.vault_id) ||
|
(!templateDefaultCred.vault_id && !overrideCred.inputs?.vault_id) ||
|
||||||
(templateDefaultCred.vault_id &&
|
(templateDefaultCred.vault_id &&
|
||||||
overrideCred.inputs.vault_id &&
|
overrideCred.inputs?.vault_id &&
|
||||||
templateDefaultCred.vault_id === overrideCred.inputs.vault_id)
|
templateDefaultCred.vault_id === overrideCred.inputs?.vault_id)
|
||||||
) {
|
) {
|
||||||
credentialHasOverride = true;
|
credentialHasOverride = true;
|
||||||
}
|
}
|
||||||
@@ -405,16 +405,7 @@ function Visualizer({ template, i18n }) {
|
|||||||
failure_nodes: [],
|
failure_nodes: [],
|
||||||
always_nodes: [],
|
always_nodes: [],
|
||||||
};
|
};
|
||||||
if (node.promptValues?.removedCredentials?.length > 0) {
|
|
||||||
node.promptValues.removedCredentials.forEach(cred => {
|
|
||||||
disassociateCredentialRequests.push(
|
|
||||||
WorkflowJobTemplateNodesAPI.disassociateCredentials(
|
|
||||||
data.id,
|
|
||||||
cred.id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (node.promptValues?.addedCredentials?.length > 0) {
|
if (node.promptValues?.addedCredentials?.length > 0) {
|
||||||
node.promptValues.addedCredentials.forEach(cred => {
|
node.promptValues.addedCredentials.forEach(cred => {
|
||||||
associateCredentialRequests.push(
|
associateCredentialRequests.push(
|
||||||
@@ -583,8 +574,9 @@ function Visualizer({ template, i18n }) {
|
|||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen
|
isOpen
|
||||||
variant="error"
|
variant="error"
|
||||||
title={i18n._(t`Error!`)}
|
title={i18n._(t`Error saving the workflow!`)}
|
||||||
onClose={dismissNodeRequestError}
|
onClose={dismissNodeRequestError}
|
||||||
|
aria-label={i18n._(t`Error saving the workflow!`)}
|
||||||
>
|
>
|
||||||
{i18n._(t`There was an error saving the workflow.`)}
|
{i18n._(t`There was an error saving the workflow.`)}
|
||||||
<ErrorDetail error={nodeRequestError} />
|
<ErrorDetail error={nodeRequestError} />
|
||||||
|
|||||||
@@ -2,13 +2,37 @@ import React from 'react';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import {
|
import {
|
||||||
|
WorkflowApprovalTemplatesAPI,
|
||||||
WorkflowJobTemplateNodesAPI,
|
WorkflowJobTemplateNodesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import Visualizer from './Visualizer';
|
import Visualizer from './Visualizer';
|
||||||
|
|
||||||
|
import workflowReducer from '../../../components/Workflow/workflowReducer';
|
||||||
|
|
||||||
|
jest.mock('../../../components/Workflow/workflowReducer');
|
||||||
|
|
||||||
|
const realWorkflowReducer = jest.requireActual(
|
||||||
|
'../../../components/Workflow/workflowReducer'
|
||||||
|
).default;
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
|
|
||||||
|
const startNode = {
|
||||||
|
id: 1,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
name: 'START',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultLinks = [
|
||||||
|
{
|
||||||
|
linkType: 'always',
|
||||||
|
source: { id: 1 },
|
||||||
|
target: { id: 2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const template = {
|
const template = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Foo WFJT',
|
name: 'Foo WFJT',
|
||||||
@@ -117,7 +141,6 @@ describe('Visualizer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
jest.clearAllMocks();
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
delete window.SVGElement.prototype.getBBox;
|
delete window.SVGElement.prototype.getBBox;
|
||||||
delete window.SVGElement.prototype.getBoundingClientRect;
|
delete window.SVGElement.prototype.getBoundingClientRect;
|
||||||
@@ -125,6 +148,12 @@ describe('Visualizer', () => {
|
|||||||
delete window.SVGElement.prototype.width;
|
delete window.SVGElement.prototype.width;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.resetModules();
|
||||||
|
workflowReducer.mockImplementation(realWorkflowReducer);
|
||||||
|
});
|
||||||
|
|
||||||
test('Renders successfully', async () => {
|
test('Renders successfully', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
@@ -185,7 +214,7 @@ describe('Visualizer', () => {
|
|||||||
wrapper.find('button#link-confirm').simulate('click');
|
wrapper.find('button#link-confirm').simulate('click');
|
||||||
expect(wrapper.find('LinkEditModal').length).toBe(0);
|
expect(wrapper.find('LinkEditModal').length).toBe(0);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
|
WorkflowJobTemplateNodesAPI.disassociateAlwaysNode
|
||||||
@@ -219,6 +248,633 @@ describe('Visualizer', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to node add error', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PING',
|
||||||
|
type: 'job_template',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.createNode.mockRejectedValue(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(WorkflowJobTemplatesAPI.createNode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to node edit error', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
isEdited: true,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'PING',
|
||||||
|
type: 'job_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.replace.mockRejectedValue(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(WorkflowJobTemplateNodesAPI.replace).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to approval template add error', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Approval',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.createNode.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9001,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.createApprovalTemplate.mockRejectedValue(
|
||||||
|
new Error()
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(WorkflowJobTemplatesAPI.createNode).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
WorkflowJobTemplateNodesAPI.createApprovalTemplate
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to approval template edit error', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
isEdited: true,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Approval',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
summary_fields: {
|
||||||
|
unified_job_template: {
|
||||||
|
unified_job_type: 'workflow_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowApprovalTemplatesAPI.update.mockRejectedValue(new Error());
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(WorkflowApprovalTemplatesAPI.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to node disassociate failure', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Approval',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
summary_fields: {
|
||||||
|
unified_job_template: {
|
||||||
|
unified_job_type: 'workflow_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [3],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
success_nodes: [3],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 4,
|
||||||
|
name: 'Approval 2',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9001,
|
||||||
|
summary_fields: {
|
||||||
|
unified_job_template: {
|
||||||
|
unified_job_type: 'workflow_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = [
|
||||||
|
{
|
||||||
|
linkType: 'always',
|
||||||
|
source: { id: 1 },
|
||||||
|
target: { id: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
linkType: 'success',
|
||||||
|
source: { id: 2 },
|
||||||
|
target: { id: 3 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateFailuresNode.mockRejectedValue(
|
||||||
|
new Error()
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateFailuresNode
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to node associate failure', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Approval',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
summary_fields: {
|
||||||
|
unified_job_template: {
|
||||||
|
unified_job_type: 'workflow_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [3],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
success_nodes: [3],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 4,
|
||||||
|
name: 'Approval 2',
|
||||||
|
timeout: 1000,
|
||||||
|
type: 'workflow_approval_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9001,
|
||||||
|
summary_fields: {
|
||||||
|
unified_job_template: {
|
||||||
|
unified_job_type: 'workflow_approval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = [
|
||||||
|
{
|
||||||
|
linkType: 'always',
|
||||||
|
source: { id: 1 },
|
||||||
|
target: { id: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
linkType: 'success',
|
||||||
|
source: { id: 2 },
|
||||||
|
target: { id: 3 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateFailuresNode.mockResolvedValue();
|
||||||
|
WorkflowJobTemplateNodesAPI.associateSuccessNode.mockRejectedValue(
|
||||||
|
new Error()
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
WorkflowJobTemplateNodesAPI.associateSuccessNode
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to credential disassociate failure', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
isEdited: true,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Ping',
|
||||||
|
type: 'job_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
originalNodeCredentials: [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptValues: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
launchConfig: {
|
||||||
|
defaults: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.replace.mockResolvedValue();
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateCredentials.mockRejectedValue(
|
||||||
|
new Error()
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateCredentials
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Error shown when saving fails due to credential associate failure', async () => {
|
||||||
|
workflowReducer.mockImplementation(state => {
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newState.nodes.length === 0) {
|
||||||
|
newState.nodes = [
|
||||||
|
startNode,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
isEdited: true,
|
||||||
|
fullUnifiedJobTemplate: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Ping',
|
||||||
|
type: 'job_template',
|
||||||
|
},
|
||||||
|
originalNodeObject: {
|
||||||
|
id: 9000,
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
originalNodeCredentials: [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
promptValues: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
launchConfig: {
|
||||||
|
defaults: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
credential_type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
success_nodes: [],
|
||||||
|
failure_nodes: [],
|
||||||
|
always_nodes: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
newState.links = defaultLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.readNodes.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.replace.mockResolvedValue();
|
||||||
|
WorkflowJobTemplateNodesAPI.disassociateCredentials.mockResolvedValue();
|
||||||
|
WorkflowJobTemplateNodesAPI.associateCredentials.mockRejectedValue(
|
||||||
|
new Error()
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<svg>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button#visualizer-save').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
WorkflowJobTemplateNodesAPI.associateCredentials
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('AlertModal[title="Error saving the workflow!"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('Error shown to user when error thrown fetching workflow nodes', async () => {
|
test('Error shown to user when error thrown fetching workflow nodes', async () => {
|
||||||
WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
|
WorkflowJobTemplatesAPI.readNodes.mockRejectedValue(new Error());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ Django Debug Toolbar (DDT)
|
|||||||
----------------
|
----------------
|
||||||
This is a useful tool for examining SQL queries, performance, headers, requests, signals, cache, logging, and more.
|
This is a useful tool for examining SQL queries, performance, headers, requests, signals, cache, logging, and more.
|
||||||
|
|
||||||
To enable DDT, you need to set your `INTERNAL_IPS` to the IP address of your load balancer. This can be overriden in `local_settings`.
|
To enable DDT, you need to set your `INTERNAL_IPS` to the IP address of your load balancer. This can be overridden by creating a new settings file beginning with `local_` in `awx/settings/` (e.g. `local_overrides.py`).
|
||||||
This IP address can be found by making a GET to any page on the browsable API and looking for a like this in the standard output:
|
This IP address can be found by making a GET to any page on the browsable API and looking for a like this in the standard output:
|
||||||
```
|
```
|
||||||
awx_1 | 14:42:08 uwsgi.1 | 172.18.0.1 GET /api/v2/tokens/ - HTTP/1.1 200
|
awx_1 | 14:42:08 uwsgi.1 | 172.18.0.1 GET /api/v2/tokens/ - HTTP/1.1 200
|
||||||
```
|
```
|
||||||
|
|
||||||
Allow this IP address by adding it to the `INTERNAL_IPS` variable in `local_settings`, then navigate to the API and you should see DDT on the
|
Allow this IP address by adding it to the `INTERNAL_IPS` variable in your new override local settings file, then navigate to the API and you should see DDT on the
|
||||||
right side. If you don't see it, make sure to set `DEBUG=True`.
|
right side. If you don't see it, make sure to set `DEBUG=True`.
|
||||||
> Note that enabling DDT is detrimental to the performance of AWX and adds overhead to every API request. It is
|
> Note that enabling DDT is detrimental to the performance of AWX and adds overhead to every API request. It is
|
||||||
recommended to keep this turned off when you are not using it.
|
recommended to keep this turned off when you are not using it.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
- log-level: debug
|
- log-level: info
|
||||||
|
|
||||||
- control-service:
|
- control-service:
|
||||||
service: control
|
service: control
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
- log-level: debug
|
- log-level: info
|
||||||
|
|
||||||
- control-service:
|
- control-service:
|
||||||
service: control
|
service: control
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
- log-level: debug
|
- log-level: info
|
||||||
|
|
||||||
- control-service:
|
- control-service:
|
||||||
service: control
|
service: control
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Docker Compose for Development
|
# Docker Compose for Development
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Clone the repo
|
||||||
|
|
||||||
|
If you have not already done so, you will need to clone, or create a local copy, of the [AWX repo](https://github.com/ansible/awx). We generally recommend that you view the releases page:
|
||||||
|
|
||||||
|
https://github.com/ansible/awx/releases/latest
|
||||||
|
|
||||||
|
...and clone the latest stable tag, e.g.,
|
||||||
|
|
||||||
|
`git clone -b x.y.z https://github.com/ansible/awx.git`
|
||||||
|
|
||||||
|
Please note that deploying from `HEAD` (or the latest commit) is **not** stable, and that if you want to do this, you should proceed at your own risk.
|
||||||
|
|
||||||
|
For more on how to clone the repo, view [git clone help](https://git-scm.com/docs/git-clone).
|
||||||
|
|
||||||
|
Once you have a local copy, run the commands in the following sections from the root of the project tree.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Here are the main make targets:
|
Here are the main make targets:
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
# MISC PROJECT SETTINGS
|
# MISC PROJECT SETTINGS
|
||||||
###############################################################################
|
###############################################################################
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Enable the following lines and install the browser extension to use Django debug toolbar
|
# Enable the following lines and install the browser extension to use Django debug toolbar
|
||||||
# if your deployment method is not VMWare of Docker-for-Mac you may
|
# if your deployment method is not VMWare of Docker-for-Mac you may
|
||||||
@@ -20,21 +18,6 @@ import sys
|
|||||||
# INTERNAL_IPS = ('172.19.0.1', '172.18.0.1', '192.168.100.1')
|
# INTERNAL_IPS = ('172.19.0.1', '172.18.0.1', '192.168.100.1')
|
||||||
# ALLOWED_HOSTS = ['*']
|
# ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
|
|
||||||
# commented out, Django will create the test_awx-dev database in PostgreSQL to
|
|
||||||
# run unit tests.
|
|
||||||
if "pytest" in sys.modules:
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'),
|
|
||||||
'TEST': {
|
|
||||||
# Test database cannot be :memory: for inventory tests.
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Location for cross-development of inventory plugins
|
# Location for cross-development of inventory plugins
|
||||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Migrating Data from Local Docker
|
# Migrating Data from Local Docker
|
||||||
|
|
||||||
If you are migrating data from a Local Docker installation (17.0.1 and prior), you can
|
If you are migrating data from a Local Docker installation (17.0.1 and prior) to AWX 18.0 or higher, you can
|
||||||
migrate your data to the development environment via the migrate.yml playbook.
|
migrate your data to the development environment via the migrate.yml playbook.
|
||||||
|
|
||||||
> Note: This will also convert your postgresql bind-mount into a docker volume.
|
> Note: This will also convert your postgresql bind-mount into a docker volume.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
- log-level: debug
|
- log-level: info
|
||||||
|
|
||||||
- control-service:
|
- control-service:
|
||||||
service: control
|
service: control
|
||||||
|
|||||||
Reference in New Issue
Block a user