From f7fdb7fe8d66c271272eff0be3934dfd94c124b9 Mon Sep 17 00:00:00 2001 From: Lorenzo Tanganelli <35271287+tanganellilore@users.noreply.github.com> Date: Thu, 27 Apr 2023 00:34:26 +0200 Subject: [PATCH] Add peers readonly api and instancelink constraint (#13916) Add Disconnected link state introspect_receptor_connections is a periodic task that examines active receptor connections and cross-checks it with the InstanceLink info. Any links that should be active but are not will be put into a Disconnected state. If active, it will be in an Established state. UI - Add hop creation and peers mgmt (#13922) * add UI for mgmt peers, instance edit and add * add peer info on detail and bug fix on detail * remove unused chip and change peer label * rename lookup, put Instance type disable on edit --------- Co-authored-by: tanganellilore --- awx/api/serializers.py | 12 +- awx/api/urls/peers.py | 14 ++ awx/api/urls/urls.py | 2 + awx/api/views/__init__.py | 14 ++ awx/api/views/root.py | 1 + awx/main/access.py | 17 ++ .../migrations/0183_auto_20230501_2000.py | 38 ++++ .../0183_instance_peers_from_control_nodes.py | 17 -- awx/main/models/ha.py | 8 +- awx/main/tasks/receptor.py | 1 + awx/main/tasks/system.py | 24 ++ .../tests/functional/api/test_instance.py | 1 + awx/settings/defaults.py | 1 + awx/ui/src/components/Lookup/Lookup.js | 12 +- awx/ui/src/components/Lookup/PeersLookup.js | 212 ++++++++++++++++++ .../src/components/Lookup/PeersLookup.test.js | 137 +++++++++++ awx/ui/src/components/Lookup/index.js | 1 + .../src/components/OptionsList/OptionsList.js | 9 +- .../Instances/InstanceAdd/InstanceAdd.test.js | 2 - .../InstanceDetail/InstanceDetail.js | 99 +++++--- .../Instances/InstanceEdit/InstanceEdit.js | 105 +++++++++ .../InstanceEdit/InstanceEdit.test.js | 149 ++++++++++++ .../screens/Instances/InstanceEdit/index.js | 1 + .../InstancePeers/InstancePeerList.js | 147 +++++++++++- .../InstancePeers/InstancePeerListItem.js | 11 +- awx/ui/src/screens/Instances/Instances.js | 9 +- .../screens/Instances/Shared/InstanceForm.js | 103 +++++++-- .../Instances/Shared/InstanceForm.test.js | 2 + awx/ui/src/types.js | 2 +- 29 files changed, 1068 insertions(+), 83 deletions(-) create mode 100644 awx/api/urls/peers.py create mode 100644 awx/main/migrations/0183_auto_20230501_2000.py delete mode 100644 awx/main/migrations/0183_instance_peers_from_control_nodes.py create mode 100755 awx/ui/src/components/Lookup/PeersLookup.js create mode 100755 awx/ui/src/components/Lookup/PeersLookup.test.js create mode 100644 awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.js create mode 100644 awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js create mode 100644 awx/ui/src/screens/Instances/InstanceEdit/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 06ca3fd3e8..2ef95f0c1a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -5356,10 +5356,16 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria class InstanceLinkSerializer(BaseSerializer): class Meta: model = InstanceLink - fields = ('source', 'target', 'link_state') + fields = ('id', 'url', 'related', 'source', 'target', 'link_state') - source = serializers.SlugRelatedField(slug_field="hostname", read_only=True) - target = serializers.SlugRelatedField(slug_field="hostname", read_only=True) + source = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all()) + target = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all()) + + def get_related(self, obj): + res = super(InstanceLinkSerializer, self).get_related(obj) + res['source_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.source.id}) + res['target_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.target.id}) + return res class InstanceNodeSerializer(BaseSerializer): diff --git a/awx/api/urls/peers.py b/awx/api/urls/peers.py new file mode 100644 index 0000000000..35f2262a83 --- /dev/null +++ b/awx/api/urls/peers.py @@ -0,0 +1,14 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.urls import re_path + +from awx.api.views import PeersList, PeersDetail + + +urls = [ + re_path(r'^$', PeersList.as_view(), name='peers_list'), + re_path(r'^(?P[0-9]+)/$', PeersDetail.as_view(), name='peers_detail'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index c74f9f97e6..73afe7be26 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -84,6 +84,7 @@ from .oauth2_root import urls as oauth2_root_urls from .workflow_approval_template import urls as workflow_approval_template_urls from .workflow_approval import urls as workflow_approval_urls from .analytics import urls as analytics_urls +from .peers import urls as peers_urls v2_urls = [ re_path(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'), @@ -153,6 +154,7 @@ v2_urls = [ re_path(r'^bulk/$', BulkView.as_view(), name='bulk'), re_path(r'^bulk/host_create/$', BulkHostCreateView.as_view(), name='bulk_host_create'), re_path(r'^bulk/job_launch/$', BulkJobLaunchView.as_view(), name='bulk_job_launch'), + re_path(r'^peers/', include(peers_urls)), ] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 1d9fe9bfd2..de432ac824 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4350,3 +4350,17 @@ class WorkflowApprovalDeny(RetrieveAPIView): return Response({"error": _("This workflow step has already been approved or denied.")}, status=status.HTTP_400_BAD_REQUEST) obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) + + +class PeersList(ListAPIView): + name = _("Peers") + model = models.InstanceLink + serializer_class = serializers.InstanceLinkSerializer + search_fields = ('source', 'target', 'link_state') + + +class PeersDetail(RetrieveAPIView): + name = _("Peers Detail") + always_allow_superuser = True + model = models.InstanceLink + serializer_class = serializers.InstanceLinkSerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 3a9a910e1c..9eaddfbbf4 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -129,6 +129,7 @@ class ApiVersionRootView(APIView): data['mesh_visualizer'] = reverse('api:mesh_visualizer_view', request=request) data['bulk'] = reverse('api:bulk', request=request) data['analytics'] = reverse('api:analytics_root_view', request=request) + data['peers'] = reverse('api:peers_list', request=request) return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index 730c0decf7..caed8afadb 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -39,6 +39,7 @@ from awx.main.models import ( Host, Instance, InstanceGroup, + InstanceLink, Inventory, InventorySource, InventoryUpdate, @@ -2949,6 +2950,22 @@ class WorkflowApprovalTemplateAccess(BaseAccess): return self.model.objects.filter(workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')) +class PeersAccess(BaseAccess): + model = InstanceLink + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return True + + def can_copy(self, obj): + return False + + for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls access_registry[UnpartitionedJobEvent] = UnpartitionedJobEventAccess diff --git a/awx/main/migrations/0183_auto_20230501_2000.py b/awx/main/migrations/0183_auto_20230501_2000.py new file mode 100644 index 0000000000..eb0a731fbb --- /dev/null +++ b/awx/main/migrations/0183_auto_20230501_2000.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.16 on 2023-05-01 20:00 + +from django.db import migrations, models +import django.db.models.expressions + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0182_constructed_inventory'), + ] + + operations = [ + migrations.AlterModelOptions( + name='instancelink', + options={'ordering': ('id',)}, + ), + migrations.AddField( + model_name='instance', + name='peers_from_control_nodes', + field=models.BooleanField(default=False, help_text='If True, control plane cluster nodes should automatically peer to it.'), + ), + migrations.AlterField( + model_name='instancelink', + name='link_state', + field=models.CharField( + choices=[('adding', 'Adding'), ('established', 'Established'), ('disconnected', 'Disconnected'), ('removing', 'Removing')], + default='disconnected', + help_text='Indicates the current life cycle stage of this peer link.', + max_length=16, + ), + ), + migrations.AddConstraint( + model_name='instancelink', + constraint=models.CheckConstraint( + check=models.Q(('source', django.db.models.expressions.F('target')), _negated=True), name='source_and_target_can_not_be_equal' + ), + ), + ] diff --git a/awx/main/migrations/0183_instance_peers_from_control_nodes.py b/awx/main/migrations/0183_instance_peers_from_control_nodes.py deleted file mode 100644 index e2e0692dba..0000000000 --- a/awx/main/migrations/0183_instance_peers_from_control_nodes.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.2.16 on 2023-04-25 19:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('main', '0182_constructed_inventory'), - ] - - operations = [ - migrations.AddField( - model_name='instance', - name='peers_from_control_nodes', - field=models.BooleanField(default=False, help_text='If True, control plane cluster nodes should automatically peer to it.'), - ), - ] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index d2b892b241..13137e85a8 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -67,14 +67,20 @@ class InstanceLink(BaseModel): class States(models.TextChoices): ADDING = 'adding', _('Adding') ESTABLISHED = 'established', _('Established') + DISCONNECTED = 'disconnected', _('Disconnected') REMOVING = 'removing', _('Removing') link_state = models.CharField( - choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.") + choices=States.choices, default=States.DISCONNECTED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.") ) class Meta: unique_together = ('source', 'target') + ordering = ("id",) + constraints = [models.CheckConstraint(check=~models.Q(source=models.F('target')), name='source_and_target_can_not_be_equal')] + + def get_absolute_url(self, request=None): + return reverse('api:peers_detail', kwargs={'pk': self.pk}, request=request) class Instance(HasPolicyEditsMixin, BaseModel): diff --git a/awx/main/tasks/receptor.py b/awx/main/tasks/receptor.py index 7516a68132..14c0a42786 100644 --- a/awx/main/tasks/receptor.py +++ b/awx/main/tasks/receptor.py @@ -679,6 +679,7 @@ RECEPTOR_CONFIG_STARTER = ( def write_receptor_config(force=False): """ only control nodes will run this + force=True means to call receptorctl reload """ lock = FileLock(__RECEPTOR_CONF_LOCKFILE) with lock: diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index 9e834f273e..5968f40808 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -512,6 +512,30 @@ def execution_node_health_check(node): return data +@task(queue=get_task_queuename) +def inspect_receptor_connections(): + ctl = get_receptor_ctl() + mesh_status = ctl.simple_command('status') + + # detect active/inactive receptor links + from awx.main.models import InstanceLink + + all_links = InstanceLink.objects.all() + active_receptor_conns = mesh_status['KnownConnectionCosts'] + update_links = [] + for link in all_links: + if link.target.hostname in active_receptor_conns.get(link.source.hostname, {}): + if link.link_state is not InstanceLink.States.ESTABLISHED: + link.link_state = InstanceLink.States.ESTABLISHED + update_links.append(link) + else: + if link.link_state is not InstanceLink.States.DISCONNECTED: + link.link_state = InstanceLink.States.DISCONNECTED + update_links.append(link) + + InstanceLink.objects.bulk_update(update_links, ['link_state']) + + def inspect_execution_nodes(instance_list): with advisory_lock('inspect_execution_nodes_lock', wait=False): node_lookup = {inst.hostname: inst for inst in instance_list} diff --git a/awx/main/tests/functional/api/test_instance.py b/awx/main/tests/functional/api/test_instance.py index b9ec4d2ab0..a2918e968e 100644 --- a/awx/main/tests/functional/api/test_instance.py +++ b/awx/main/tests/functional/api/test_instance.py @@ -84,5 +84,6 @@ def test_custom_hostname_regex(post, admin_user): "hostname": value[0], "node_type": "execution", "node_state": "installed", + "peers": [], } post(url=url, user=admin_user, data=data, expect=value[1]) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 59ea79f112..647c500b21 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -463,6 +463,7 @@ CELERYBEAT_SCHEDULE = { 'schedule': timedelta(seconds=CLUSTER_NODE_HEARTBEAT_PERIOD), 'options': {'expires': 50}, }, + 'inspect_receptor_connections': {'task': 'awx.main.tasks.system.inspect_receptor_connections', 'schedule': timedelta(seconds=20)}, 'gather_analytics': {'task': 'awx.main.tasks.system.gather_analytics', 'schedule': timedelta(minutes=5)}, 'task_manager': {'task': 'awx.main.scheduler.tasks.task_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}}, 'dependency_manager': {'task': 'awx.main.scheduler.tasks.dependency_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}}, diff --git a/awx/ui/src/components/Lookup/Lookup.js b/awx/ui/src/components/Lookup/Lookup.js index fdcb98cb55..eaa190677b 100644 --- a/awx/ui/src/components/Lookup/Lookup.js +++ b/awx/ui/src/components/Lookup/Lookup.js @@ -223,6 +223,10 @@ function Lookup(props) { const Item = shape({ id: number.isRequired, }); +const InstanceItem = shape({ + id: number.isRequired, + hostname: string.isRequired, +}); Lookup.propTypes = { id: string, @@ -230,7 +234,13 @@ Lookup.propTypes = { modalDescription: oneOfType([string, node]), onChange: func.isRequired, onUpdate: func, - value: oneOfType([Item, arrayOf(Item), object]), + value: oneOfType([ + Item, + arrayOf(Item), + object, + InstanceItem, + arrayOf(InstanceItem), + ]), multiple: bool, required: bool, onBlur: func, diff --git a/awx/ui/src/components/Lookup/PeersLookup.js b/awx/ui/src/components/Lookup/PeersLookup.js new file mode 100755 index 0000000000..c93d241329 --- /dev/null +++ b/awx/ui/src/components/Lookup/PeersLookup.js @@ -0,0 +1,212 @@ +import React, { useCallback, useEffect } from 'react'; +import { arrayOf, string, func, bool, shape } from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { t } from '@lingui/macro'; +import { FormGroup, Chip } from '@patternfly/react-core'; +import { InstancesAPI } from 'api'; +import { Instance } from 'types'; +import { getSearchableKeys } from 'components/PaginatedTable'; +import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; +import useRequest from 'hooks/useRequest'; +import Popover from '../Popover'; +import OptionsList from '../OptionsList'; +import Lookup from './Lookup'; +import LookupErrorMessage from './shared/LookupErrorMessage'; +import FieldWithPrompt from '../FieldWithPrompt'; + +const QS_CONFIG = getQSConfig('instances', { + page: 1, + page_size: 5, + order_by: 'hostname', +}); + +function PeersLookup({ + id, + value, + onChange, + tooltip, + className, + required, + history, + fieldName, + multiple, + validate, + columns, + isPromptableField, + promptId, + promptName, + formLabel, + typePeers, + instance_details, +}) { + const { + result: { instances, count, relatedSearchableKeys, searchableKeys }, + request: fetchInstances, + error, + isLoading, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const peersFilter = {}; + if (typePeers) { + peersFilter.not__node_type = ['control', 'hybrid']; + if (instance_details) { + if (instance_details.id) { + peersFilter.not__id = instance_details.id; + peersFilter.not__hostname = instance_details.peers; + } + } + } + + const [{ data }, actionsResponse] = await Promise.all([ + InstancesAPI.read( + mergeParams(params, { + ...peersFilter, + }) + ), + InstancesAPI.readOptions(), + ]); + return { + instances: data.results, + count: data.count, + relatedSearchableKeys: ( + actionsResponse?.data?.related_search_fields || [] + ).map((val) => val.slice(0, -8)), + searchableKeys: getSearchableKeys(actionsResponse.data.actions?.GET), + }; + }, [history.location, typePeers, instance_details]), + { + instances: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const renderLookup = () => ( + <> + ( + removeItem(item)} + isReadOnly={!canDelete} + > + {item.hostname} + + )} + renderOptionsList={({ state, dispatch, canDelete }) => ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={(item) => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + ); + + return isPromptableField ? ( + + {renderLookup()} + + ) : ( + } + fieldId={id} + > + {renderLookup()} + + ); +} + +PeersLookup.propTypes = { + id: string, + value: arrayOf(Instance).isRequired, + tooltip: string, + onChange: func.isRequired, + className: string, + required: bool, + validate: func, + multiple: bool, + fieldName: string, + columns: arrayOf(Object), + formLabel: string, + instance_details: (Instance, shape({})), + typePeers: bool, +}; + +PeersLookup.defaultProps = { + id: 'instances', + tooltip: '', + className: '', + required: false, + validate: () => undefined, + fieldName: 'instances', + columns: [ + { + key: 'hostname', + name: t`Hostname`, + }, + { + key: 'node_type', + name: t`Node Type`, + }, + ], + formLabel: t`Instances`, + instance_details: {}, + multiple: true, + typePeers: false, +}; + +export default withRouter(PeersLookup); diff --git a/awx/ui/src/components/Lookup/PeersLookup.test.js b/awx/ui/src/components/Lookup/PeersLookup.test.js new file mode 100755 index 0000000000..3823bf4766 --- /dev/null +++ b/awx/ui/src/components/Lookup/PeersLookup.test.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { InstancesAPI } from 'api'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import PeersLookup from './PeersLookup'; + +jest.mock('../../api'); + +const mockedInstances = { + count: 1, + results: [ + { + id: 2, + name: 'Foo', + image: 'quay.io/ansible/awx-ee', + pull: 'missing', + }, + ], +}; + +const instances = [ + { + id: 1, + hostname: 'awx_1', + type: 'instance', + url: '/api/v2/instances/1/', + related: { + named_url: '/api/v2/instances/awx_1/', + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + peers: '/api/v2/instances/1/peers/', + }, + summary_fields: { + user_capabilities: { + edit: false, + }, + links: [], + }, + uuid: '00000000-0000-0000-0000-000000000000', + created: '2023-04-26T22:06:46.766198Z', + modified: '2023-04-26T22:06:46.766217Z', + last_seen: '2023-04-26T23:12:02.857732Z', + health_check_started: null, + health_check_pending: false, + last_health_check: '2023-04-26T23:01:13.941693Z', + errors: 'Instance received normal shutdown signal', + capacity_adjustment: '1.00', + version: '0.1.dev33237+g1fdef52', + capacity: 0, + consumed_capacity: 0, + percent_capacity_remaining: 0, + jobs_running: 0, + jobs_total: 0, + cpu: '8.0', + memory: 8011055104, + cpu_capacity: 0, + mem_capacity: 0, + enabled: true, + managed_by_policy: true, + node_type: 'hybrid', + node_state: 'installed', + ip_address: null, + listener_port: 27199, + peers: [], + peers_from_control_nodes: false, + }, +]; + +describe('PeersLookup', () => { + let wrapper; + + beforeEach(() => { + InstancesAPI.read.mockResolvedValue({ + data: mockedInstances, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render successfully without instance_details (for new added instance)', async () => { + InstancesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + {}} /> + + ); + }); + wrapper.update(); + expect(InstancesAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('PeersLookup')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instances"]').length).toBe(1); + expect(wrapper.find('Checkbox[aria-label="Prompt on launch"]').length).toBe( + 0 + ); + }); + test('should render successfully with instance_details for edit instance', async () => { + InstancesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + {}} + /> + + ); + }); + wrapper.update(); + expect(InstancesAPI.read).toHaveBeenCalledTimes(1); + expect(wrapper.find('PeersLookup')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instances"]').length).toBe(1); + expect(wrapper.find('Checkbox[aria-label="Prompt on launch"]').length).toBe( + 0 + ); + }); +}); diff --git a/awx/ui/src/components/Lookup/index.js b/awx/ui/src/components/Lookup/index.js index 7c8b6845b1..5a60287ffd 100644 --- a/awx/ui/src/components/Lookup/index.js +++ b/awx/ui/src/components/Lookup/index.js @@ -8,3 +8,4 @@ export { default as ApplicationLookup } from './ApplicationLookup'; export { default as HostFilterLookup } from './HostFilterLookup'; export { default as OrganizationLookup } from './OrganizationLookup'; export { default as ExecutionEnvironmentLookup } from './ExecutionEnvironmentLookup'; +export { default as PeersLookup } from './PeersLookup'; diff --git a/awx/ui/src/components/OptionsList/OptionsList.js b/awx/ui/src/components/OptionsList/OptionsList.js index bd9d4459c3..fbf044f741 100644 --- a/awx/ui/src/components/OptionsList/OptionsList.js +++ b/awx/ui/src/components/OptionsList/OptionsList.js @@ -125,19 +125,24 @@ const Item = shape({ name: string.isRequired, url: string, }); +const InstanceItem = shape({ + id: oneOfType([number, string]).isRequired, + hostname: string.isRequired, + url: string, +}); OptionsList.propTypes = { deselectItem: func.isRequired, displayKey: string, isSelectedDraggable: bool, multiple: bool, optionCount: number.isRequired, - options: arrayOf(Item).isRequired, + options: oneOfType([arrayOf(Item), arrayOf(InstanceItem)]).isRequired, qsConfig: QSConfig.isRequired, renderItemChip: func, searchColumns: SearchColumns, selectItem: func.isRequired, sortColumns: SortColumns, - value: arrayOf(Item).isRequired, + value: oneOfType([arrayOf(Item), arrayOf(InstanceItem)]).isRequired, }; OptionsList.defaultProps = { isSelectedDraggable: false, diff --git a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js index e79b0471c8..1c4d8d1d1c 100644 --- a/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js +++ b/awx/ui/src/screens/Instances/InstanceAdd/InstanceAdd.test.js @@ -32,12 +32,10 @@ describe('', () => { await waitForElement(wrapper, 'isLoading', (el) => el.length === 0); await act(async () => { wrapper.find('InstanceForm').prop('handleSubmit')({ - name: 'new Foo', node_type: 'hop', }); }); expect(InstancesAPI.create).toHaveBeenCalledWith({ - name: 'new Foo', node_type: 'hop', }); expect(history.location.pathname).toBe('/instances/13/details'); diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js index 5284bf1bab..fd643c5edd 100644 --- a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory, useParams, Link } from 'react-router-dom'; import { t, Plural } from '@lingui/macro'; import { Button, @@ -116,6 +116,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { setBreadcrumb(instance); } }, [instance, setBreadcrumb]); + const { error: healthCheckError, request: fetchHealthCheck } = useRequest( useCallback(async () => { const { status } = await InstancesAPI.healthCheck(id); @@ -205,13 +206,39 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { } /> + + + {(isExecutionNode || isHopNode) && ( + + )} + {instance.related?.install_bundle && ( + + + + } + /> + )} {!isHopNode && ( <> - {instanceGroups && ( @@ -327,9 +354,20 @@ function InstanceDetail({ setBreadcrumb, isK8s }) { /> )} - {!isHopNode && ( - - {config?.me?.is_superuser && isK8s && isExecutionNode && ( + + {config?.me?.is_superuser && isK8s && (isExecutionNode || isHopNode) && ( + + )} + {config?.me?.is_superuser && + isK8s && + (isExecutionNode || isHopNode) && ( )} - {isExecutionNode && ( - - - - )} - - - )} + {isExecutionNode && ( + + + + )} + + {error && ( { + try { + await InstancesAPI.update(id, values); + history.push(detailsUrl); + } catch (err) { + setFormError(err); + } + }; + + const handleCancel = () => { + history.push(detailsUrl); + }; + + const { + isLoading, + error, + request: fetchDetail, + result: { instance, peers }, + } = useRequest( + useCallback(async () => { + const [{ data: instance_detail }, { data: peers_detail }] = + await Promise.all([ + InstancesAPI.readDetail(id), + InstancesAPI.readPeers(id), + ]); + return { + instance: instance_detail, + peers: peers_detail.results, + }; + }, [id]), + { + instance: {}, + peers: [], + } + ); + + useEffect(() => { + fetchDetail(); + }, [fetchDetail]); + + useEffect(() => { + if (instance) { + setBreadcrumb(instance); + } + }, [instance, setBreadcrumb]); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {error?.response?.status === 404 && ( + + {t`Instance not found.`}{' '} + {t`View all Instances.`} + + )} + + + ); + } + + return ( + + + + + + ); +} + +export default InstanceEdit; diff --git a/awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js b/awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js new file mode 100644 index 0000000000..e28fab1d8d --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js @@ -0,0 +1,149 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import useDebounce from 'hooks/useDebounce'; +import { InstancesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import InstanceEdit from './InstanceEdit'; + +jest.mock('../../../api'); +jest.mock('../../../hooks/useDebounce'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 42, + }), +})); + +const instanceData = { + id: 42, + hostname: 'awx_1', + type: 'instance', + url: '/api/v2/instances/1/', + related: { + named_url: '/api/v2/instances/awx_1/', + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + peers: '/api/v2/instances/1/peers/', + }, + summary_fields: { + user_capabilities: { + edit: false, + }, + links: [], + }, + uuid: '00000000-0000-0000-0000-000000000000', + created: '2023-04-26T22:06:46.766198Z', + modified: '2023-04-26T22:06:46.766217Z', + last_seen: '2023-04-26T23:12:02.857732Z', + health_check_started: null, + health_check_pending: false, + last_health_check: '2023-04-26T23:01:13.941693Z', + errors: 'Instance received normal shutdown signal', + capacity_adjustment: '1.00', + version: '0.1.dev33237+g1fdef52', + capacity: 0, + consumed_capacity: 0, + percent_capacity_remaining: 0, + jobs_running: 0, + jobs_total: 0, + cpu: '8.0', + memory: 8011055104, + cpu_capacity: 0, + mem_capacity: 0, + enabled: true, + managed_by_policy: true, + node_type: 'hybrid', + node_state: 'installed', + ip_address: null, + listener_port: 27199, + peers: [], + peers_from_control_nodes: false, +}; + +const instanceDataWithPeers = { + results: [instanceData], +}; + +const updatedInstance = { + node_type: 'hop', + peers: ['test-peer'], +}; + +describe('', () => { + let wrapper; + let history; + + beforeAll(async () => { + useDebounce.mockImplementation((fn) => fn); + history = createMemoryHistory(); + InstancesAPI.readDetail.mockResolvedValue({ data: instanceData }); + InstancesAPI.readPeers.mockResolvedValue({ data: instanceDataWithPeers }); + + await act(async () => { + wrapper = mountWithContexts( + {}} + />, + { + context: { router: { history } }, + } + ); + }); + expect(InstancesAPI.readDetail).toBeCalledWith(42); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', async () => { + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('InstanceEdit')).toHaveLength(1); + }); + + test('handleSubmit should call the api and redirect to details page', async () => { + await act(async () => { + wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance); + }); + expect(InstancesAPI.update).toHaveBeenCalledWith(42, updatedInstance); + expect(history.location.pathname).toEqual('/instances/42/details'); + }); + + test('should navigate to instance details when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + }); + expect(history.location.pathname).toEqual('/instances/42/details'); + }); + + test('should navigate to instance details after successful submission', async () => { + await act(async () => { + wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance); + }); + wrapper.update(); + expect(wrapper.find('submitError').length).toBe(0); + expect(history.location.pathname).toEqual('/instances/42/details'); + }); + + test('failed form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InstancesAPI.update.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceEdit/index.js b/awx/ui/src/screens/Instances/InstanceEdit/index.js new file mode 100644 index 0000000000..674e1b3e00 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceEdit/index.js @@ -0,0 +1 @@ +export { default } from './InstanceEdit'; diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js index a1d104d667..c72b8eb512 100644 --- a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js @@ -1,17 +1,23 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { t } from '@lingui/macro'; import { CardBody } from 'components/Card'; import PaginatedTable, { getSearchableKeys, HeaderCell, HeaderRow, + ToolbarAddButton, } from 'components/PaginatedTable'; -import { getQSConfig, parseQueryString } from 'util/qs'; +import DisassociateButton from 'components/DisassociateButton'; +import AssociateModal from 'components/AssociateModal'; +import ErrorDetail from 'components/ErrorDetail'; +import AlertModal from 'components/AlertModal'; +import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; import { useLocation, useParams } from 'react-router-dom'; -import useRequest from 'hooks/useRequest'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; import DataListToolbar from 'components/DataListToolbar'; import { InstancesAPI } from 'api'; import useExpanded from 'hooks/useExpanded'; +import useSelected from 'hooks/useSelected'; import InstancePeerListItem from './InstancePeerListItem'; const QS_CONFIG = getQSConfig('peer', { @@ -20,27 +26,35 @@ const QS_CONFIG = getQSConfig('peer', { order_by: 'hostname', }); -function InstancePeerList() { +function InstancePeerList({ setBreadcrumb }) { const location = useLocation(); const { id } = useParams(); + const [isModalOpen, setIsModalOpen] = useState(false); + const readInstancesOptions = useCallback( + () => InstancesAPI.readOptions(id), + [id] + ); const { isLoading, error: contentError, request: fetchPeers, - result: { peers, count, relatedSearchableKeys, searchableKeys }, + result: { instance, peers, count, relatedSearchableKeys, searchableKeys }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); const [ + { data: detail }, { data: { results, count: itemNumber }, }, actions, ] = await Promise.all([ + InstancesAPI.readDetail(id), InstancesAPI.readPeers(id, params), InstancesAPI.readOptions(), ]); return { + instance: detail, peers: results, count: itemNumber, relatedSearchableKeys: (actions?.data?.related_search_fields || []).map( @@ -50,6 +64,7 @@ function InstancePeerList() { }; }, [id, location]), { + instance: {}, peers: [], count: 0, relatedSearchableKeys: [], @@ -61,18 +76,87 @@ function InstancePeerList() { fetchPeers(); }, [fetchPeers]); + useEffect(() => { + if (instance) { + setBreadcrumb(instance); + } + }, [instance, setBreadcrumb]); + const { expanded, isAllExpanded, handleExpand, expandAll } = useExpanded(peers); + const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = + useSelected(peers); + + const fetchInstancesToAssociate = useCallback( + (params) => + InstancesAPI.read( + mergeParams(params, { + ...{ not__id: id }, + ...{ not__node_type: ['control', 'hybrid'] }, + ...{ not__hostname: instance.peers }, + }) + ), + [id, instance] + ); + + const { + isLoading: isAssociateLoading, + request: handlePeerAssociate, + error: associateError, + } = useRequest( + useCallback( + async (instancesPeerToAssociate) => { + const selected_hostname = instancesPeerToAssociate.map( + (obj) => obj.hostname + ); + const new_peers = [ + ...new Set([...instance.peers, ...selected_hostname]), + ]; + await InstancesAPI.update(instance.id, { peers: new_peers }); + fetchPeers(); + }, + [instance, fetchPeers] + ) + ); + + const { + isLoading: isDisassociateLoading, + request: handlePeersDiassociate, + error: disassociateError, + } = useRequest( + useCallback(async () => { + const new_peers = []; + const selected_hostname = selected.map((obj) => obj.hostname); + for (let i = 0; i < instance.peers.length; i++) { + if (!selected_hostname.includes(instance.peers[i])) { + new_peers.push(instance.peers[i]); + } + } + await InstancesAPI.update(instance.id, { peers: new_peers }); + fetchPeers(); + }, [instance, selected, fetchPeers]) + ); + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + + const isHopNode = instance.node_type === 'hop'; + const isExecutionNode = instance.node_type === 'execution'; return ( ( setIsModalOpen(true)} + /> + ), + (isExecutionNode || isHopNode) && ( + + ), + ]} /> )} renderRow={(peer, index) => ( row.id === peer.id)} + onSelect={() => handleSelect(peer)} isExpanded={expanded.some((row) => row.id === peer.id)} onExpand={() => handleExpand(peer)} key={peer.id} @@ -116,6 +223,34 @@ function InstancePeerList() { /> )} /> + {isModalOpen && ( + setIsModalOpen(false)} + title={t`Select Instances`} + optionsRequest={readInstancesOptions} + displayKey="hostname" + columns={[ + { key: 'hostname', name: t`Name` }, + { key: 'node_type', name: t`Node Type` }, + ]} + /> + )} + {error && ( + + {associateError && t`Failed to associate peer.`} + {disassociateError && t`Failed to remove peers.`} + + + )} ); } diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js index cce09300b0..35ab17f3fc 100644 --- a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js @@ -10,6 +10,8 @@ import { Detail, DetailList } from 'components/DetailList'; function InstancePeerListItem({ peerInstance, + isSelected, + onSelect, isExpanded, onExpand, rowIndex, @@ -33,7 +35,14 @@ function InstancePeerListItem({ }} /> )} - + {peerInstance.hostname} diff --git a/awx/ui/src/screens/Instances/Instances.js b/awx/ui/src/screens/Instances/Instances.js index ca42498e41..eefc7d2716 100644 --- a/awx/ui/src/screens/Instances/Instances.js +++ b/awx/ui/src/screens/Instances/Instances.js @@ -7,6 +7,7 @@ import PersistentFilters from 'components/PersistentFilters'; import { InstanceList } from './InstanceList'; import Instance from './Instance'; import InstanceAdd from './InstanceAdd'; +import InstanceEdit from './InstanceEdit'; function Instances() { const [breadcrumbConfig, setBreadcrumbConfig] = useState({ @@ -20,8 +21,11 @@ function Instances() { } setBreadcrumbConfig({ '/instances': t`Instances`, + '/instances/add': t`Create new Instance`, [`/instances/${instance.id}`]: `${instance.hostname}`, [`/instances/${instance.id}/details`]: t`Details`, + [`/instances/${instance.id}/peers`]: t`Peers`, + [`/instances/${instance.id}/edit`]: t`Edit Instance`, }); }, []); @@ -30,7 +34,10 @@ function Instances() { - + + + + diff --git a/awx/ui/src/screens/Instances/Shared/InstanceForm.js b/awx/ui/src/screens/Instances/Shared/InstanceForm.js index 9b8ffa2ce7..c5392940bf 100644 --- a/awx/ui/src/screens/Instances/Shared/InstanceForm.js +++ b/awx/ui/src/screens/Instances/Shared/InstanceForm.js @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { t } from '@lingui/macro'; -import { Formik } from 'formik'; +import { Formik, useField, useFormikContext } from 'formik'; import { Form, FormGroup, CardBody } from '@patternfly/react-core'; import { FormColumnLayout } from 'components/FormLayout'; import FormField, { @@ -8,9 +8,31 @@ import FormField, { CheckboxField, } from 'components/FormField'; import FormActionGroup from 'components/FormActionGroup'; +import AnsibleSelect from 'components/AnsibleSelect'; +import { PeersLookup } from 'components/Lookup'; import { required } from 'util/validators'; -function InstanceFormFields() { +const INSTANCE_TYPES = [ + { id: 'execution', name: t`Execution` }, + { id: 'hop', name: t`Hop` }, +]; + +function InstanceFormFields({ isEdit }) { + const [instanceTypeField, instanceTypeMeta, instanceTypeHelpers] = useField({ + name: 'node_type', + validate: required(t`Set a value for this field`), + }); + + const { setFieldValue } = useFormikContext(); + + const [peersField, peersMeta, peersHelpers] = useField('peers'); + + const handlePeersUpdate = useCallback( + (value) => { + setFieldValue('peers', value); + }, + [setFieldValue] + ); return ( <> - + ({ + key: type.id, + value: type.id, + label: type.name, + }))} + onChange={(event, value) => { + instanceTypeHelpers.setValue(value); + }} + isDisabled={isEdit} + /> + + peersHelpers.setTouched()} + onChange={handlePeersUpdate} + value={peersField.value} + tooltip={t`Select the Peers Instances.`} + fieldName="peers" + formLabel={t`Peers`} + multiple + typePeers + id="peers" + isRequired /> + ); @@ -71,6 +132,8 @@ function InstanceFormFields() { function InstanceForm({ instance = {}, + instance_peers = [], + isEdit = false, submitError, handleCancel, handleSubmit, @@ -79,22 +142,28 @@ function InstanceForm({ { - handleSubmit(values); + handleSubmit({ + ...values, + peers: values.peers.map((peer) => peer.hostname || peer), + }); }} > {(formik) => (
- + ', () => { listener_port: 'This is a repeat song', node_state: 'installed', node_type: 'execution', + peers_from_control_nodes: true, + peers: [], }); }); }); diff --git a/awx/ui/src/types.js b/awx/ui/src/types.js index 677f4edae8..e81ee356ab 100644 --- a/awx/ui/src/types.js +++ b/awx/ui/src/types.js @@ -122,7 +122,7 @@ export const InstanceGroup = shape({ export const Instance = shape({ id: number.isRequired, - name: string.isRequired, + hostname: string.isRequired, }); export const Label = shape({