From 7e627e1d1e0bd22a5ca4844c44cba97cf2c0c296 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 26 Aug 2022 09:46:40 -0400 Subject: [PATCH] Adds Instance Peers Tab and update Instance Details view with more data (#12655) * Adds InstancePeers tab and updates details view * attempt to fix failing api tests --- awx/api/serializers.py | 1 + awx/api/urls/instance.py | 11 +- awx/api/views/__init__.py | 13 + awx/main/access.py | 1 + awx/main/tests/functional/test_tasks.py | 5 +- awx/ui/src/api/models/Instances.js | 4 + awx/ui/src/screens/Instances/Instance.js | 37 ++- .../InstanceDetail/InstanceDetail.js | 78 +++++- .../InstanceDetail/InstanceDetail.test.js | 11 + .../Instances/InstanceList/InstanceList.js | 8 +- .../InstancePeers/InstancePeerList.js | 164 ++++++++++++ .../InstancePeers/InstancePeerListItem.js | 247 ++++++++++++++++++ .../screens/Instances/InstancePeers/index.js | 1 + 13 files changed, 567 insertions(+), 14 deletions(-) create mode 100644 awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js create mode 100644 awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js create mode 100644 awx/ui/src/screens/Instances/InstancePeers/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 25b389a77e..4f94175afd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4919,6 +4919,7 @@ class InstanceSerializer(BaseSerializer): res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk}) if settings.IS_K8S and obj.node_type in ('execution', 'hop'): res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk}) + res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk}) if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor: if obj.node_type != 'hop': res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk}) diff --git a/awx/api/urls/instance.py b/awx/api/urls/instance.py index a9ef203384..8dad087b82 100644 --- a/awx/api/urls/instance.py +++ b/awx/api/urls/instance.py @@ -3,7 +3,15 @@ from django.urls import re_path -from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck, InstanceInstallBundle +from awx.api.views import ( + InstanceList, + InstanceDetail, + InstanceUnifiedJobsList, + InstanceInstanceGroupsList, + InstanceHealthCheck, + InstanceInstallBundle, + InstancePeersList, +) urls = [ @@ -12,6 +20,7 @@ urls = [ re_path(r'^(?P[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'), re_path(r'^(?P[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'), re_path(r'^(?P[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'), + re_path(r'^(?P[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'), re_path(r'^(?P[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'), ] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 9991bcfe1c..5d95e13b98 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -384,6 +384,8 @@ class InstanceDetail(RetrieveUpdateAPIView): obj = self.get_object() obj.set_capacity_value() obj.save(update_fields=['capacity']) + for instance in models.Instance.objects.filter(node_type__in=['control', 'hybrid']): + models.InstanceLink.objects.create(source=instance, target=obj) r.data = serializers.InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj) return r @@ -402,6 +404,17 @@ class InstanceUnifiedJobsList(SubListAPIView): return qs +class InstancePeersList(SubListAPIView): + + name = _("Instance Peers") + parent_model = models.Instance + model = models.Instance + serializer_class = serializers.InstanceSerializer + parent_access = 'read' + search_fields = {'hostname'} + relationship = 'peers' + + class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): name = _("Instance's Instance Groups") diff --git a/awx/main/access.py b/awx/main/access.py index a11789ee81..665c8e1f8d 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -579,6 +579,7 @@ class InstanceAccess(BaseAccess): return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data) def can_add(self, data): + return self.user.is_superuser def can_change(self, obj, data): diff --git a/awx/main/tests/functional/test_tasks.py b/awx/main/tests/functional/test_tasks.py index ce385cfced..6de551cf9f 100644 --- a/awx/main/tests/functional/test_tasks.py +++ b/awx/main/tests/functional/test_tasks.py @@ -19,12 +19,11 @@ def scm_revision_file(tmpdir_factory): @pytest.mark.django_db -@pytest.mark.parametrize('node_type', ('control', 'hybrid')) +@pytest.mark.parametrize('node_type', ('control. hybrid')) def test_no_worker_info_on_AWX_nodes(node_type): hostname = 'us-south-3-compute.invalid' Instance.objects.create(hostname=hostname, node_type=node_type) - with pytest.raises(RuntimeError): - execution_node_health_check(hostname) + assert execution_node_health_check(hostname) is None @pytest.fixture diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 07ee085c14..460b809ec1 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -18,6 +18,10 @@ class Instances extends Base { return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } + readPeers(instanceId) { + return this.http.get(`${this.baseUrl}${instanceId}/peers`); + } + readInstanceGroup(instanceId) { return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`); } diff --git a/awx/ui/src/screens/Instances/Instance.js b/awx/ui/src/screens/Instances/Instance.js index 8efe4b55f6..535bb1ea39 100644 --- a/awx/ui/src/screens/Instances/Instance.js +++ b/awx/ui/src/screens/Instances/Instance.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import { t } from '@lingui/macro'; import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom'; @@ -6,7 +6,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons'; import { Card, PageSection } from '@patternfly/react-core'; import ContentError from 'components/ContentError'; import RoutedTabs from 'components/RoutedTabs'; +import useRequest from 'hooks/useRequest'; +import { SettingsAPI } from 'api'; +import ContentLoading from 'components/ContentLoading'; import InstanceDetail from './InstanceDetail'; +import InstancePeerList from './InstancePeers'; function Instance({ setBreadcrumb }) { const match = useRouteMatch(); @@ -25,6 +29,32 @@ function Instance({ setBreadcrumb }) { { name: t`Details`, link: `${match.url}/details`, id: 0 }, ]; + const { + result: { isK8s }, + error, + isLoading, + request, + } = useRequest( + useCallback(async () => { + const { data } = await SettingsAPI.readCategory('system'); + return data.IS_K8S; + }, []), + { isK8s: false, isLoading: true } + ); + useEffect(() => { + request(); + }, [request]); + + if (isK8s) { + tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 1 }); + } + if (isLoading) { + return ; + } + + if (error) { + return ; + } return ( @@ -34,6 +64,11 @@ function Instance({ setBreadcrumb }) { + {isK8s && ( + + + + )} {match.params.id && ( diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js index 9338b7c8d2..9d3ae47864 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 { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { t, Plural } from '@lingui/macro'; import { Button, @@ -11,7 +11,9 @@ import { CodeBlockCode, Tooltip, Slider, + Chip, } from '@patternfly/react-core'; +import { DownloadIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { useConfig } from 'contexts/Config'; @@ -27,6 +29,7 @@ import ContentLoading from 'components/ContentLoading'; import { Detail, DetailList } from 'components/DetailList'; import StatusLabel from 'components/StatusLabel'; import useRequest, { useDismissableError } from 'hooks/useRequest'; +import ChipGroup from 'components/ChipGroup'; const Unavailable = styled.span` color: var(--pf-global--danger-color--200); @@ -65,10 +68,18 @@ function InstanceDetail({ setBreadcrumb }) { isLoading, error: contentError, request: fetchDetails, - result: instance, + result: { instance, instanceGroups }, } = useRequest( useCallback(async () => { - const { data: details } = await InstancesAPI.readDetail(id); + const [ + { data: details }, + { + data: { results }, + }, + ] = await Promise.all([ + InstancesAPI.readDetail(id), + InstancesAPI.readInstanceGroup(id), + ]); if (details.node_type !== 'hop') { const { data: healthCheckData } = @@ -83,9 +94,12 @@ function InstanceDetail({ setBreadcrumb }) { details.capacity_adjustment ) ); - return details; + return { + instance: details, + instanceGroups: results, + }; }, [id]), - {} + { instance: {}, instanceGroups: [] } ); useEffect(() => { fetchDetails(); @@ -127,6 +141,11 @@ function InstanceDetail({ setBreadcrumb }) { debounceUpdateInstance({ capacity_adjustment: roundedValue }); }; + const buildLinkURL = (inst) => + inst.is_container_group + ? '/instance_groups/container_group/' + : '/instance_groups/'; + const { error, dismissError } = useDismissableError( updateInstanceError || healthCheckError ); @@ -137,6 +156,7 @@ function InstanceDetail({ setBreadcrumb }) { return ; } const isHopNode = instance.node_type === 'hop'; + return ( @@ -158,12 +178,60 @@ function InstanceDetail({ setBreadcrumb }) { label={t`Policy Type`} value={instance.managed_by_policy ? t`Auto` : t`Manual`} /> + + {instanceGroups && ( + + {instanceGroups.map((ig) => ( + + + {ig.name} + + + ))} + + } + isEmpty={instanceGroups.length === 0} + /> + )} + {instance.related?.install_bundle && ( + + + + } + /> + )} ', () => { InstancesAPI.readDetail.mockResolvedValue({ data: { + related: {}, id: 1, type: 'instance', url: '/api/v2/instances/1/', @@ -51,6 +52,16 @@ describe('', () => { node_type: 'hybrid', }, }); + InstancesAPI.readInstanceGroup.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Foo', + }, + ], + }, + }); InstancesAPI.readHealthCheckDetail.mockResolvedValue({ data: { uuid: '00000000-0000-0000-0000-000000000000', diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js index a50891bc68..ec3b59be9c 100644 --- a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -33,7 +33,7 @@ function InstanceList() { const { me } = useConfig(); const { - result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 }, + result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s }, error: contentError, isLoading, request: fetchInstances, @@ -47,7 +47,7 @@ function InstanceList() { ]); return { instances: response.data.results, - isK8: sysSettings.data.IS_K8S, + isK8s: sysSettings.data.IS_K8S, count: response.data.count, actions: responseActions.data.actions, relatedSearchableKeys: ( @@ -62,7 +62,7 @@ function InstanceList() { actions: {}, relatedSearchableKeys: [], searchableKeys: [], - isK8: false, + isK8s: false, } ); @@ -142,7 +142,7 @@ function InstanceList() { onExpandAll={expandAll} qsConfig={QS_CONFIG} additionalControls={[ - ...(isK8 && me.is_superuser + ...(isK8s && me.is_superuser ? [ { + const [ + { + data: { results, count: itemNumber }, + }, + actions, + ] = await Promise.all([ + InstancesAPI.readPeers(id), + InstancesAPI.readOptions(), + ]); + return { + peers: results, + count: itemNumber, + relatedSearchableKeys: (actions?.data?.related_search_fields || []).map( + (val) => val.slice(0, -8) + ), + searchableKeys: getSearchableKeys(actions.data.actions?.GET), + }; + }, [id]), + { + peers: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + useEffect(() => fetchPeers(), [fetchPeers, id]); + + const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = + useSelected(peers.filter((i) => i.node_type !== 'hop')); + + const { + error: healthCheckError, + request: fetchHealthCheck, + isLoading: isHealthCheckLoading, + } = useRequest( + useCallback(async () => { + await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ instanceId }) => InstancesAPI.healthCheck(instanceId)) + ); + fetchPeers(); + }, [selected, fetchPeers]) + ); + const handleHealthCheck = async () => { + await fetchHealthCheck(); + clearSelected(); + }; + + const { error, dismissError } = useDismissableError(healthCheckError); + + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(peers); + + return ( + + ( + , + ]} + /> + )} + headerRow={ + + {t`Name`} + + } + renderRow={(peer, index) => ( + handleSelect(peer)} + isSelected={selected.some((row) => row.id === peer.id)} + isExpanded={expanded.some((row) => row.id === peer.id)} + onExpand={() => handleExpand(peer)} + key={peer.id} + peerInstance={peer} + rowIndex={index} + fetchInstance={fetchPeers} + /> + )} + /> + {error && ( + + {t`Failed to run a health check on one or more peers.`} + + + )} + + ); +} + +export default InstancePeerList; diff --git a/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js new file mode 100644 index 0000000000..defa7c11d9 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/InstancePeerListItem.js @@ -0,0 +1,247 @@ +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { t, Plural } from '@lingui/macro'; +import styled from 'styled-components'; +import 'styled-components/macro'; +import { + Progress, + ProgressMeasureLocation, + ProgressSize, + Slider, + Tooltip, +} from '@patternfly/react-core'; +import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; +import { formatDateString } from 'util/dates'; +import computeForks from 'util/computeForks'; +import { ActionsTd, ActionItem } from 'components/PaginatedTable'; +import InstanceToggle from 'components/InstanceToggle'; +import StatusLabel from 'components/StatusLabel'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import useDebounce from 'hooks/useDebounce'; +import { InstancesAPI } from 'api'; +import { useConfig } from 'contexts/Config'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import { Detail, DetailList } from 'components/DetailList'; + +const Unavailable = styled.span` + color: var(--pf-global--danger-color--200); +`; + +const SliderHolder = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const SliderForks = styled.div` + flex-grow: 1; + margin-right: 8px; + margin-left: 8px; + text-align: center; +`; + +function InstancePeerListItem({ + peerInstance, + fetchInstances, + isSelected, + onSelect, + isExpanded, + onExpand, + rowIndex, +}) { + const { me = {} } = useConfig(); + const [forks, setForks] = useState( + computeForks( + peerInstance.mem_capacity, + peerInstance.cpu_capacity, + peerInstance.capacity_adjustment + ) + ); + const labelId = `check-action-${peerInstance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {t`Unavailable`}; + } + + const { error: updateInstanceError, request: updateInstance } = useRequest( + useCallback( + async (values) => { + await InstancesAPI.update(peerInstance.id, values); + }, + [peerInstance] + ) + ); + + const { error: updateError, dismissError: dismissUpdateError } = + useDismissableError(updateInstanceError); + + const debounceUpdateInstance = useDebounce(updateInstance, 200); + + const handleChangeValue = (value) => { + const roundedValue = Math.round(value * 100) / 100; + setForks( + computeForks( + peerInstance.mem_capacity, + peerInstance.cpu_capacity, + roundedValue + ) + ); + debounceUpdateInstance({ capacity_adjustment: roundedValue }); + }; + const isHopNode = peerInstance.node_type === 'hop'; + return ( + <> + + {isHopNode ? ( + + ) : ( + + )} + + + + {peerInstance.hostname} + + + + + + {t`Last Health Check`} +   + {formatDateString( + peerInstance.last_health_check ?? peerInstance.last_seen + )} + + } + > + + + + + {peerInstance.node_type} + {!isHopNode && ( + <> + + +
{t`CPU ${peerInstance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${peerInstance.mem_capacity}`}
+
+ + + + {usedCapacity(peerInstance)} + + + + + + + + + )} + + {!isHopNode && ( + + + + + + + + + + + + + + )} + {updateError && ( + + {t`Failed to update capacity adjustment.`} + + + )} + + ); +} + +export default InstancePeerListItem; diff --git a/awx/ui/src/screens/Instances/InstancePeers/index.js b/awx/ui/src/screens/Instances/InstancePeers/index.js new file mode 100644 index 0000000000..1be96e8381 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstancePeers/index.js @@ -0,0 +1 @@ +export { default } from './InstancePeerList';