diff --git a/awx/ui/src/components/PaginatedTable/ToolbarAddButton.js b/awx/ui/src/components/PaginatedTable/ToolbarAddButton.js index 27c2ca6fe0..afe59449b7 100644 --- a/awx/ui/src/components/PaginatedTable/ToolbarAddButton.js +++ b/awx/ui/src/components/PaginatedTable/ToolbarAddButton.js @@ -56,6 +56,7 @@ function ToolbarAddButton({ return ( + + + + )} + + {error && ( + + {updateInstanceError + ? t`Failed to update capacity adjustment.` + : t`Failed to disassociate one or more instances.`} + + + )} + + + ); +} + +export default InstanceDetail; diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js new file mode 100644 index 0000000000..72836e1792 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.test.js @@ -0,0 +1,222 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import * as ConfigContext from 'contexts/Config'; +import useDebounce from 'hooks/useDebounce'; +import { InstancesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import InstanceDetail from './InstanceDetail'; + +jest.mock('../../../api'); +jest.mock('../../../hooks/useDebounce'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); + +describe('', () => { + let wrapper; + beforeEach(() => { + useDebounce.mockImplementation((fn) => fn); + + InstancesAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + 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 () => { + 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('InstanceDetail')).toHaveLength(1); + + expect(InstancesAPI.readDetail).toBeCalledWith(1); + expect(InstancesAPI.readHealthCheckDetail).toBeCalledWith(1); + expect( + wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled') + ).toBe(false); + }); + + test('should calculate number of forks when slide changes', async () => { + 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('InstanceDetail').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 () => { + 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='health-check-button']").prop('isDisabled') + ).toBe(true); + }); + + test('should display instance toggle', async () => { + 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 make request for Health Check', async () => { + InstancesAPI.healthCheck.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, + }, + }); + 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.healthCheck).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.healthCheck.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/instances/1/health_check', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + 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.healthCheck).toBeCalledWith(1); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(1); + expect(wrapper.find('ErrorDetail')).toHaveLength(1); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceDetail/index.js b/awx/ui/src/screens/Instances/InstanceDetail/index.js new file mode 100644 index 0000000000..21bc62ec4a --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceDetail/index.js @@ -0,0 +1 @@ +export { default } from './InstanceDetail'; diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js new file mode 100644 index 0000000000..5788245d3e --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect } from 'react'; + +import { Plural, t } from '@lingui/macro'; +import { useLocation } from 'react-router-dom'; +import 'styled-components/macro'; + +import useExpanded from 'hooks/useExpanded'; +import DataListToolbar from 'components/DataListToolbar'; +import PaginatedTable, { + HeaderRow, + HeaderCell, + getSearchableKeys, +} from 'components/PaginatedTable'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; + +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import useSelected from 'hooks/useSelected'; +import { InstancesAPI } from 'api'; +import { getQSConfig, parseQueryString } from 'util/qs'; + +import { Button, Tooltip, PageSection, Card } from '@patternfly/react-core'; +import InstanceListItem from './InstanceListItem'; + +const QS_CONFIG = getQSConfig('instance', { + page: 1, + page_size: 20, + order_by: 'hostname', +}); + +function InstanceList() { + const location = useLocation(); + + const { + result: { instances, count, relatedSearchableKeys, searchableKeys }, + error: contentError, + isLoading, + request: fetchInstances, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, responseActions] = await Promise.all([ + InstancesAPI.read(params), + InstancesAPI.readOptions(), + ]); + return { + instances: response.data.results, + count: response.data.count, + actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map((val) => val.slice(0, -8)), + searchableKeys: getSearchableKeys(responseActions.data.actions?.GET), + }; + }, [location.search]), + { + instances: [], + count: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + const { selected, isAllSelected, handleSelect, clearSelected, selectAll } = + useSelected(instances); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const { error: healthCheckError, request: fetchHealthCheck } = useRequest( + useCallback(async () => { + await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ id }) => InstancesAPI.healthCheck(id)) + ); + fetchInstances(); + clearSelected(); + }, [selected, clearSelected, fetchInstances]) + ); + + const { error, dismissError } = useDismissableError(healthCheckError); + + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(instances); + + const hopNodeSelected = selected.filter( + (instance) => instance.node_type === 'hop' + ).length; + + const buildTooltip = () => { + if (hopNodeSelected) { + return ( + + ); + } + return selected.length ? ( + + ) : ( + t`Select an instance to run a health check.` + ); + }; + + return ( + <> + + + ( + +
+ +
+ , + ]} + /> + )} + headerRow={ + + {t`Name`} + {t`Status`} + {t`Node Type`} + {t`Capacity Adjustment`} + {t`Used Capacity`} + {t`Actions`} + + } + renderRow={(instance, index) => ( + row.id === instance.id)} + onExpand={() => handleExpand(instance)} + key={instance.id} + value={instance.hostname} + instance={instance} + onSelect={() => handleSelect(instance)} + isSelected={selected.some((row) => row.id === instance.id)} + fetchInstances={fetchInstances} + rowIndex={index} + /> + )} + /> +
+
+ {error && ( + + {t`Failed to run a health check on one or more instances.`} + + + )} + + ); +} + +export default InstanceList; diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js b/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js new file mode 100644 index 0000000000..b355f35bec --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js @@ -0,0 +1,211 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { InstancesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; + +import InstanceList from './InstanceList'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'control', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'hybrid', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, + { + id: 3, + type: 'instance', + url: '/api/v2/instances/3/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'execution', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: false, + managed_by_policy: true, + }, + { + id: 4, + type: 'instance', + url: '/api/v2/instances/4/', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + node_type: 'hop', + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: false, + managed_by_policy: true, + }, +]; + +describe('', () => { + let wrapper; + + const options = { data: { actions: { POST: true } } }; + + beforeEach(async () => { + InstancesAPI.read.mockResolvedValue({ + data: { + count: instances.length, + results: instances, + }, + }); + InstancesAPI.readOptions.mockResolvedValue(options); + const history = createMemoryHistory({ + initialEntries: ['/instances/1'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should have data fetched', () => { + expect(wrapper.find('InstanceList').length).toBe(1); + }); + + test('should fetch instances from the api and render them in the list', () => { + expect(InstancesAPI.read).toHaveBeenCalled(); + expect(InstancesAPI.readOptions).toHaveBeenCalled(); + expect(wrapper.find('InstanceListItem').length).toBe(4); + }); + + test('should run health check', async () => { + // Ensures health check button is disabled on mount + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(true); + await act(async () => + wrapper.find('DataListToolbar').prop('onSelectAll')(instances) + ); + wrapper.update(); + + // Ensures health check button is disabled because a hop node is among + // the selected. + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(true); + + await act(async () => + wrapper.find('input[aria-label="Select row 3"]').prop('onChange')(false) + ); + wrapper.update(); + await act(async () => + wrapper.find('Button[ouiaId="health-check"]').prop('onClick')() + ); + expect(InstancesAPI.healthCheck).toBeCalledTimes(3); + }); + test('should render health check error', async () => { + InstancesAPI.healthCheck.mockRejectedValue( + new Error({ + response: { + config: { + method: 'create', + url: '/api/v2/instances', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(true); + await act(async () => + wrapper.find('input[aria-label="Select row 1"]').prop('onChange')(true) + ); + wrapper.update(); + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(false); + await act(async () => + wrapper.find('Button[ouiaId="health-check"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(1); + }); + + test('Health check button should remain disabled', async () => { + await act(async () => + wrapper.find('input[aria-label="Select row 3"]').prop('onChange')(true) + ); + wrapper.update(); + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(true); + expect(wrapper.find('Tooltip[ouiaId="healthCheckTooltip"]').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js new file mode 100644 index 0000000000..8a8bb9df6d --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js @@ -0,0 +1,239 @@ +import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { bool, func } from 'prop-types'; +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 { ActionsTd, ActionItem } from 'components/PaginatedTable'; +import InstanceToggle from 'components/InstanceToggle'; +import StatusLabel from 'components/StatusLabel'; +import { Instance } from 'types'; +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 computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) { + const minCapacity = Math.min(memCapacity, cpuCapacity); + const maxCapacity = Math.max(memCapacity, cpuCapacity); + + return Math.floor( + minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment + ); +} + +function InstanceListItem({ + instance, + isExpanded, + onExpand, + isSelected, + onSelect, + fetchInstances, + rowIndex, +}) { + const { me = {} } = useConfig(); + const [forks, setForks] = useState( + computeForks( + instance.mem_capacity, + instance.cpu_capacity, + instance.capacity_adjustment + ) + ); + + const labelId = `check-action-${instance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {t`Unavailable`}; + } + + const { error: updateInstanceError, request: updateInstance } = useRequest( + useCallback( + async (values) => { + await InstancesAPI.update(instance.id, values); + }, + [instance] + ) + ); + + const { error: updateError, dismissError: dismissUpdateError } = + useDismissableError(updateInstanceError); + + 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 }); + }; + + return ( + <> + + + + + + {instance.hostname} + + + + + {t`Last Health Check`} +   + {formatDateString(instance.last_health_check)} + + } + > + + + + {instance.node_type} + {instance.node_type !== 'hop' && ( + + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+
+ + )} + {instance.node_type !== 'hop' && ( + + {usedCapacity(instance)} + + )} + {instance.node_type !== 'hop' && ( + + + + + + )} + + + + + + + + + + + + + + + {updateError && ( + + {t`Failed to update capacity adjustment.`} + + + )} + + ); +} + +InstanceListItem.prototype = { + instance: Instance.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default InstanceListItem; diff --git a/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js new file mode 100644 index 0000000000..10e8b2985a --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.test.js @@ -0,0 +1,298 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { InstancesAPI } from 'api'; +import useDebounce from 'hooks/useDebounce'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +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, + type: 'instance', + url: '/api/v2/instances/1/', + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + last_health_check: '2021-09-15T18:02:07.270664Z', + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + node_type: 'hybrid', + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/1/', + uuid: '00000000-0000-0000-0000-000000000001', + hostname: 'awx-control', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + last_health_check: '2021-09-15T18:02:07.270664Z', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + node_type: 'hop', + }, +]; + +describe('', () => { + let wrapper; + + beforeEach(() => { + useDebounce.mockImplementation((fn) => fn); + }); + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + fetchInstances={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('InstanceListItem').length).toBe(1); + }); + + test('should calculate number of forks when slide changes', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + fetchInstances={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('InstanceListItem').length).toBe(1); + expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain( + '10 forks' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(1); + }); + + wrapper.update(); + expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain( + '24 forks' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(0); + }); + wrapper.update(); + expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain( + '1 fork' + ); + + await act(async () => { + wrapper.find('Slider').prop('onChange')(0.5); + }); + wrapper.update(); + expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain( + '12 forks' + ); + }); + + test('should render the proper data instance', async () => { + await act(async () => { + wrapper = mountWithContexts( + + + {}} + fetchInstances={() => {}} + /> + +
+ ); + }); + expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe( + '/instances/1/details' + ); + expect(wrapper.find('Td').at(2).text()).toBe('awx'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper + .find('Td') + .at(5) + .containsMatchingElement(
CPU 24
) + ); + expect( + wrapper + .find('Td') + .at(5) + .containsMatchingElement(
RAM 24
) + ); + expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain( + '10 forks' + ); + }); + + test('should render checkbox', async () => { + const onSelect = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + }); + expect(wrapper.find('Td').at(1).prop('select').onSelect).toEqual(onSelect); + }); + + test('should display instance toggle', () => { + expect(wrapper.find('InstanceToggle').length).toBe(1); + }); + + test('should display error', async () => { + jest.useFakeTimers(); + InstancesAPI.update.mockRejectedValue( + new Error({ + response: { + config: { + method: 'patch', + url: '/api/v2/instances/1', + data: { capacity_adjustment: 0.30001 }, + }, + data: { + capacity_adjustment: [ + 'Ensure that there are no more than 3 digits in total.', + ], + }, + status: 400, + statusText: 'Bad Request', + }, + }) + ); + await act(async () => { + wrapper = mountWithContexts( + + + {}} + fetchInstances={() => {}} + /> + +
, + { context: { network: { handleHttpError: () => {} } } } + ); + }); + await act(async () => { + wrapper.update(); + }); + expect(wrapper.find('ErrorDetail').length).toBe(0); + await act(async () => { + wrapper.find('Slider').prop('onChange')(0.30001); + }); + await act(async () => { + wrapper.update(); + }); + jest.advanceTimersByTime(210); + await act(async () => { + wrapper.update(); + }); + expect(wrapper.find('ErrorDetail').length).toBe(1); + }); + + test('Should render expanded row with the correct data points', async () => { + const onSelect = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + + {}} + isExpanded + /> + +
+ ); + }); + expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true); + + expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe( + 'Auto' + ); + expect( + wrapper.find('Detail[label="Last Health Check"]').prop('value') + ).toBe('9/15/2021, 6:02:07 PM'); + }); + test('Hop should not render some things', async () => { + const onSelect = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + + {}} + /> + +
+ ); + }); + expect(wrapper.find('InstanceToggle').length).toBe(0); + expect( + wrapper.find("Td[dataLabel='Instance group used capacity']").length + ).toBe(0); + expect(wrapper.find("Td[dataLabel='Capacity Adjustment']").length).toBe(0); + }); +}); diff --git a/awx/ui/src/screens/Instances/InstanceList/index.js b/awx/ui/src/screens/Instances/InstanceList/index.js new file mode 100644 index 0000000000..2567e3c8e7 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/index.js @@ -0,0 +1,2 @@ +export { default as InstanceList } from './InstanceList'; +export { default as InstanceListItem } from './InstanceListItem'; diff --git a/awx/ui/src/screens/Instances/Instances.js b/awx/ui/src/screens/Instances/Instances.js new file mode 100644 index 0000000000..2b42f14449 --- /dev/null +++ b/awx/ui/src/screens/Instances/Instances.js @@ -0,0 +1,43 @@ +import React, { useCallback, useState } from 'react'; + +import { t } from '@lingui/macro'; +import { Route, Switch } from 'react-router-dom'; +import ScreenHeader from 'components/ScreenHeader'; +import { InstanceList } from './InstanceList'; +import Instance from './Instance'; + +function Instances() { + const [breadcrumbConfig, setBreadcrumbConfig] = useState({ + '/instances': t`Instances`, + }); + + const buildBreadcrumbConfig = useCallback((instance) => { + if (!instance) { + return; + } + setBreadcrumbConfig({ + '/instances': t`Instances`, + [`/instances/${instance.id}`]: t`${instance.hostname}`, + [`/instances/${instance.id}/details`]: t`Details`, + }); + }, []); + + return ( + <> + + + + + + + + + + + ); +} + +export default Instances; diff --git a/awx/ui/src/screens/Instances/index.js b/awx/ui/src/screens/Instances/index.js new file mode 100644 index 0000000000..b018ebb049 --- /dev/null +++ b/awx/ui/src/screens/Instances/index.js @@ -0,0 +1 @@ +export { default } from './Instances';