diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 41fa06d5f7..43608cae04 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -4,6 +4,17 @@ class Instances extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/instances/'; + + this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this); + this.createHealthCheck = this.createHealthCheck.bind(this); + } + + createHealthCheck(instanceId) { + return this.http.post(`${this.baseUrl}${instanceId}/health_check/`); + } + + readHealthCheckDetail(instanceId) { + return this.http.get(`${this.baseUrl}${instanceId}/health_check/`); } } diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.js b/awx/ui/src/components/StatusLabel/StatusLabel.js index 30d250a4d1..a8486f3de1 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.js @@ -29,6 +29,7 @@ const RunningIcon = styled(SyncAltIcon)` const colors = { success: 'green', successful: 'green', + healthy: 'green', failed: 'red', error: 'red', running: 'blue', @@ -39,6 +40,7 @@ const colors = { }; const icons = { success: CheckCircleIcon, + healthy: CheckCircleIcon, successful: CheckCircleIcon, failed: ExclamationCircleIcon, error: ExclamationCircleIcon, @@ -52,6 +54,7 @@ const icons = { export default function StatusLabel({ status, tooltipContent = '' }) { const upperCaseStatus = { success: t`Success`, + healthy: t`Healthy`, successful: t`Successful`, failed: t`Failed`, error: t`Error`, @@ -88,6 +91,7 @@ StatusLabel.propTypes = { status: oneOf([ 'success', 'successful', + 'healthy', 'failed', 'error', 'running', diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js new file mode 100644 index 0000000000..69685e4473 --- /dev/null +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.js @@ -0,0 +1,308 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { useParams, useHistory } from 'react-router-dom'; +import { t, Plural } from '@lingui/macro'; +import { + Button, + Progress, + ProgressMeasureLocation, + ProgressSize, + CodeBlock, + CodeBlockCode, + Tooltip, + Slider, +} from '@patternfly/react-core'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +import { useConfig } from 'contexts/Config'; +import { InstancesAPI, InstanceGroupsAPI } from 'api'; +import useDebounce from 'hooks/useDebounce'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import DisassociateButton from 'components/DisassociateButton'; +import InstanceToggle from 'components/InstanceToggle'; +import { CardBody, CardActionsRow } from 'components/Card'; +import { formatDateString } from 'util/dates'; +import RoutedTabs from 'components/RoutedTabs'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import { Detail, DetailList } from 'components/DetailList'; +import StatusLabel from 'components/StatusLabel'; +import useRequest, { + useDeleteItems, + useDismissableError, +} from 'hooks/useRequest'; + +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 computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) { + const minCapacity = Math.min(memCapacity, cpuCapacity); + const maxCapacity = Math.max(memCapacity, cpuCapacity); + + return Math.floor( + minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment + ); +} + +function InstanceDetails({ setBreadcrumb, instanceGroup }) { + const { me = {} } = useConfig(); + const { id, instanceId } = useParams(); + const history = useHistory(); + + const [healthCheck, setHealthCheck] = useState({}); + const [forks, setForks] = useState(); + + const { + isLoading, + error: contentError, + request: fetchDetails, + result: { instance }, + } = useRequest( + useCallback(async () => { + const { + data: { results }, + } = await InstanceGroupsAPI.readInstances(instanceGroup.id); + let instanceDetails; + let healthCheckDetails; + const isAssociated = results.some( + ({ id: instId }) => instId === parseInt(instanceId, 10) + ); + + if (isAssociated) { + const [{ data: details }, { data: healthCheckData }] = + await Promise.all([ + InstancesAPI.readDetail(instanceId), + InstancesAPI.readHealthCheckDetail(instanceId), + ]); + + instanceDetails = details; + healthCheckDetails = healthCheckData; + } else { + throw new Error( + `This instance is not associated with this instance group` + ); + } + + setBreadcrumb(instanceGroup, instanceDetails); + setHealthCheck(healthCheckDetails); + setForks( + computeForks( + instanceDetails.mem_capacity, + instanceDetails.cpu_capacity, + instanceDetails.capacity_adjustment + ) + ); + return { instance: instanceDetails }; + }, [instanceId, setBreadcrumb, instanceGroup]), + { instance: {}, isLoading: true } + ); + useEffect(() => { + fetchDetails(); + }, [fetchDetails]); + + const { error: healthCheckError, request: fetchHealthCheck } = useRequest( + useCallback(async () => { + const { data } = await InstancesAPI.createHealthCheck(instanceId); + setHealthCheck(data); + }, [instanceId]) + ); + + const { + deleteItems: disassociateInstance, + deletionError: disassociateError, + } = useDeleteItems( + useCallback(async () => { + await InstanceGroupsAPI.disassociateInstance( + instanceGroup.id, + instance.id + ); + history.push(`/instance_groups/${instanceGroup.id}/instances`); + }, [instanceGroup.id, instance.id, history]) + ); + + const { error: updateInstanceError, request: updateInstance } = useRequest( + useCallback( + async (values) => { + await InstancesAPI.update(instance.id, values); + }, + [instance] + ) + ); + + const debounceUpdateInstance = useDebounce(updateInstance, 200); + + const handleChangeValue = (value) => { + const roundedValue = Math.round(value * 100) / 100; + setForks( + computeForks(instance.mem_capacity, instance.cpu_capacity, roundedValue) + ); + debounceUpdateInstance({ capacity_adjustment: roundedValue }); + }; + + const { error, dismissError } = useDismissableError( + disassociateError || updateInstanceError || healthCheckError + ); + + const tabsArray = [ + { + name: ( + <> + + {t`Back to Instances`} + + ), + link: `/instance_groups/${id}/instances`, + id: 99, + }, + { + name: t`Details`, + link: `/instance_groups/${id}/instances/${instanceId}/details`, + id: 0, + }, + ]; + if (contentError) { + return ; + } + if (isLoading) { + return ; + } + + return ( + <> + + + + + + } + /> + + + + + + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+ + } + /> + + ) : ( + {t`Unavailable`} + ) + } + /> + {healthCheck?.errors && ( + + {healthCheck?.errors} + + } + /> + )} +
+ + + + + + + + + {error && ( + + {updateInstanceError + ? t`Failed to update capacity adjustment.` + : t`Failed to disassociate one or more instances.`} + + + )} +
+ + ); +} + +export default InstanceDetails; diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js new file mode 100644 index 0000000000..48a215f969 --- /dev/null +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js @@ -0,0 +1,490 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import * as ConfigContext from 'contexts/Config'; +import useDebounce from 'hooks/useDebounce'; +import { InstancesAPI, InstanceGroupsAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import InstanceDetails from './InstanceDetails'; + +jest.mock('../../../api'); +jest.mock('../../../hooks/useDebounce'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 2, + instanceId: 1, + }), +})); + +const instanceGroup = { + id: 2, + type: 'instance_group', + url: '/api/v2/instance_groups/2/', + related: { + named_url: '/api/v2/instance_groups/default/', + jobs: '/api/v2/instance_groups/2/jobs/', + instances: '/api/v2/instance_groups/2/instances/', + }, + name: 'default', + created: '2021-09-08T17:10:39.947029Z', + modified: '2021-09-08T17:10:39.959187Z', + capacity: 38, + committed_capacity: 0, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 0, + instances: 3, + is_container_group: false, + credential: null, + policy_instance_percentage: 100, + policy_instance_minimum: 0, + policy_instance_list: ['receptor-1', 'receptor-2'], + pod_spec_override: '', + summary_fields: { + user_capabilities: { + edit: true, + delete: true, + }, + }, +}; +describe('', () => { + let wrapper; + beforeEach(() => { + useDebounce.mockImplementation((fn) => fn); + + InstancesAPI.readDetail.mockResolvedValue({ + data: { + id: 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/', + health_check: '/api/v2/instances/1/health_check/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx_1', + created: '2021-09-08T17:10:34.484569Z', + modified: '2021-09-09T13:55:44.219900Z', + last_seen: '2021-09-09T20:20:31.623148Z', + last_health_check: '2021-09-09T20:20:31.623148Z', + errors: '', + capacity_adjustment: '1.00', + version: '19.1.0', + capacity: 38, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 0, + cpu: 8, + memory: 6232231936, + cpu_capacity: 32, + mem_capacity: 38, + enabled: true, + managed_by_policy: true, + node_type: 'hybrid', + }, + }); + InstancesAPI.readHealthCheckDetail.mockResolvedValue({ + data: { + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx_1', + version: '19.1.0', + last_health_check: '2021-09-10T16:16:19.729676Z', + errors: '', + cpu: 8, + memory: 6232231936, + cpu_capacity: 32, + mem_capacity: 38, + capacity: 38, + }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('Should render proper data', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_superuser: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('InstanceDetails')).toHaveLength(1); + + expect(InstanceGroupsAPI.readInstances).toBeCalledWith(2); + expect(InstancesAPI.readHealthCheckDetail).toBeCalledWith(1); + expect(InstancesAPI.readDetail).toBeCalledWith(1); + expect( + wrapper.find("Button[ouiaId='disassociate-button']").prop('isDisabled') + ).toBe(false); + expect( + wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') + ).toBe(false); + }); + + test('should calculate number of forks when slide changes', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_superuser: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + + expect(wrapper.find('InstanceDetails').length).toBe(1); + expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain( + '38 forks' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(4); + }); + + wrapper.update(); + + expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain( + '56 forks' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(0); + }); + wrapper.update(); + expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain( + '32 forks' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(0.5); + }); + wrapper.update(); + expect(wrapper.find('div[data-cy="number-forks"]').text()).toContain( + '35 forks' + ); + }); + + test('buttons should be disabled', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_system_auditor: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect( + wrapper.find("Button[ouiaId='disassociate-button']").prop('isDisabled') + ).toBe(true); + expect( + wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') + ).toBe(true); + }); + + test('should display instance toggle', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_system_auditor: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('InstanceToggle').length).toBe(1); + }); + + test('should throw error because intance is not associated with instance group', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 3, + }, + { + id: 3, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_superuser: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('ContentError')).toHaveLength(1); + expect(InstanceGroupsAPI.readInstances).toBeCalledWith(2); + expect(InstancesAPI.readHealthCheckDetail).not.toBeCalled(); + expect(InstancesAPI.readDetail).not.toBeCalled(); + }); + + test('Should make request for Health Check', async () => { + InstancesAPI.createHealthCheck.mockResolvedValue({ + data: { + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx_1', + version: '19.1.0', + last_health_check: '2021-09-15T18:02:07.270664Z', + errors: '', + cpu: 8, + memory: 6232231936, + cpu_capacity: 32, + mem_capacity: 38, + capacity: 38, + }, + }); + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_superuser: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect( + wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') + ).toBe(false); + await act(async () => { + wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); + }); + expect(InstancesAPI.createHealthCheck).toBeCalledWith(1); + wrapper.update(); + expect( + wrapper.find("Detail[label='Last Health Check']").prop('value') + ).toBe('9/15/2021, 6:02:07 PM'); + }); + + test('Should handle api error for health check', async () => { + InstancesAPI.createHealthCheck.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/instances/1/health_check', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_superuser: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect( + wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') + ).toBe(false); + await act(async () => { + wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); + }); + expect(InstancesAPI.createHealthCheck).toBeCalledWith(1); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(1); + expect(wrapper.find('ErrorDetail')).toHaveLength(1); + }); + + test('Should call disassociate', async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_system_auditor: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + await act(async () => + wrapper.find('Button[ouiaId="disassociate-button"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => + wrapper + .find('Button[ouiaId="disassociate-modal-confirm"]') + .prop('onClick')() + ); + wrapper.update(); + + expect(InstanceGroupsAPI.disassociateInstance).toHaveBeenCalledWith(2, 1); + }); + + test('Should throw disassociate error', async () => { + InstanceGroupsAPI.disassociateInstance.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/instance_groups', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + results: [ + { + id: 1, + }, + { + id: 2, + }, + ], + }, + }); + jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({ + me: { is_system_auditor: true }, + })); + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + await act(async () => + wrapper.find('Button[ouiaId="disassociate-button"]').prop('onClick')() + ); + wrapper.update(); + await act(async () => + wrapper + .find('Button[ouiaId="disassociate-modal-confirm"]') + .prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(1); + expect(wrapper.find('ErrorDetail')).toHaveLength(1); + }); +}); diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/index.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/index.js new file mode 100644 index 0000000000..930572ad8c --- /dev/null +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/index.js @@ -0,0 +1 @@ +export { default } from './InstanceDetails'; diff --git a/awx/ui/src/screens/InstanceGroup/InstanceGroup.js b/awx/ui/src/screens/InstanceGroup/InstanceGroup.js index 5761495fdb..67793759e4 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceGroup.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceGroup.js @@ -21,7 +21,7 @@ import JobList from 'components/JobList'; import InstanceGroupDetails from './InstanceGroupDetails'; import InstanceGroupEdit from './InstanceGroupEdit'; -import InstanceList from './Instances/InstanceList'; +import Instances from './Instances/Instances'; function InstanceGroup({ setBreadcrumb }) { const { id } = useParams(); @@ -108,7 +108,8 @@ function InstanceGroup({ setBreadcrumb }) { } let cardHeader = ; - if (pathname.endsWith('edit')) { + + if (['edit', 'instances/'].some((name) => pathname.includes(name))) { cardHeader = null; } @@ -139,7 +140,10 @@ function InstanceGroup({ setBreadcrumb }) { /> - + { + const buildBreadcrumbConfig = useCallback((instanceGroups, instance) => { if (!instanceGroups) { return; } @@ -49,6 +49,8 @@ function InstanceGroups() { [`/instance_groups/${instanceGroups.id}/details`]: t`Details`, [`/instance_groups/${instanceGroups.id}/instances`]: t`Instances`, + [`/instance_groups/${instanceGroups.id}/instances/${instance?.id}`]: `${instance?.hostname}`, + [`/instance_groups/${instanceGroups.id}/instances/${instance?.id}/details`]: t`Instance details`, [`/instance_groups/${instanceGroups.id}/jobs`]: t`Jobs`, [`/instance_groups/${instanceGroups.id}/edit`]: t`Edit details`, [`/instance_groups/${instanceGroups.id}`]: `${instanceGroups.name}`, diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js index d3891e3755..a1e072347b 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js @@ -1,4 +1,5 @@ import React, { useState, useCallback } from 'react'; +import { Link, useParams } from 'react-router-dom'; import { bool, func } from 'prop-types'; import { t, Plural } from '@lingui/macro'; import styled from 'styled-components'; @@ -62,6 +63,8 @@ function InstanceListItem({ instance.capacity_adjustment ) ); + const { id } = useParams(); + const labelId = `check-action-${instance.id}`; function usedCapacity(item) { @@ -113,7 +116,9 @@ function InstanceListItem({ dataLabel={t`Selected`} /> - {instance.hostname} + + {instance.hostname} + {instance.node_type} diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js index 681dfd9482..a2efc6c5b6 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js @@ -10,6 +10,12 @@ import InstanceListItem from './InstanceListItem'; jest.mock('../../../api'); jest.mock('../../../hooks/useDebounce'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); const instance = [ { id: 1, @@ -153,6 +159,9 @@ describe('', () => { ); }); + expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe( + '/instance_groups/1/instances/1/details' + ); expect(wrapper.find('Td').at(1).text()).toBe('awx'); expect(wrapper.find('Progress').prop('value')).toBe(40); expect(wrapper.find('Td').at(2).text()).toBe('hybrid'); diff --git a/awx/ui/src/screens/InstanceGroup/Instances/Instances.js b/awx/ui/src/screens/InstanceGroup/Instances/Instances.js new file mode 100644 index 0000000000..f77bad9d6d --- /dev/null +++ b/awx/ui/src/screens/InstanceGroup/Instances/Instances.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import InstanceList from './InstanceList'; +import InstanceDetails from '../InstanceDetails'; + +function Instances({ setBreadcrumb, instanceGroup }) { + return ( + + + + + + + + + + ); +} + +export default Instances; diff --git a/awx/ui/src/screens/InstanceGroup/Instances/index.js b/awx/ui/src/screens/InstanceGroup/Instances/index.js index 2567e3c8e7..e58909444f 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/index.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/index.js @@ -1,2 +1,3 @@ export { default as InstanceList } from './InstanceList'; export { default as InstanceListItem } from './InstanceListItem'; +export { default as Instances } from './Instances';