diff --git a/awx/ui/src/components/HealthCheckButton/HealthCheckButton.js b/awx/ui/src/components/HealthCheckButton/HealthCheckButton.js new file mode 100644 index 0000000000..e13cb45277 --- /dev/null +++ b/awx/ui/src/components/HealthCheckButton/HealthCheckButton.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { Plural, t } from '@lingui/macro'; +import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; +import { useKebabifiedMenu } from 'contexts/Kebabified'; + +function HealthCheckButton({ isDisabled, onClick, selectedItems }) { + const { isKebabified } = useKebabifiedMenu(); + + const selectedItemsCount = selectedItems.length; + + const buildTooltip = () => + selectedItemsCount ? ( + + ) : ( + t`Select an instance to run a health check.` + ); + + if (isKebabified) { + return ( + + + {t`Health Check`} + + + ); + } + return ( + +
+ +
+
+ ); +} + +export default HealthCheckButton; diff --git a/awx/ui/src/components/HealthCheckButton/index.js b/awx/ui/src/components/HealthCheckButton/index.js new file mode 100644 index 0000000000..8d02a31aea --- /dev/null +++ b/awx/ui/src/components/HealthCheckButton/index.js @@ -0,0 +1 @@ +export { default } from './HealthCheckButton'; diff --git a/awx/ui/src/components/PaginatedTable/HeaderRow.js b/awx/ui/src/components/PaginatedTable/HeaderRow.js index 6abca05239..56d23934e0 100644 --- a/awx/ui/src/components/PaginatedTable/HeaderRow.js +++ b/awx/ui/src/components/PaginatedTable/HeaderRow.js @@ -69,6 +69,7 @@ export function HeaderCell({ idPrefix, className, children, + tooltip, }) { const sort = sortKey ? { @@ -79,6 +80,11 @@ export function HeaderCell({ : null; return ( {tooltip}, + } + } id={sortKey ? `${idPrefix}-${sortKey}` : null} className={className} sort={sort} 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 ( - - , + , ]} emptyStateControls={ canAdd ? ( @@ -252,8 +253,7 @@ function InstanceList() { {t`Name`} {t`Status`} - {t`Running Jobs`} - {t`Total Jobs`} + {t`Node Type`} {t`Capacity Adjustment`} {t`Used Capacity`} {t`Actions`} diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js index 3ff460cf1a..5d3c2e25a8 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js @@ -19,6 +19,7 @@ import StatusLabel from 'components/StatusLabel'; import { Instance } from 'types'; import useRequest, { useDismissableError } from 'hooks/useRequest'; import useDebounce from 'hooks/useDebounce'; +import computeForks from 'util/computeForks'; import { InstancesAPI } from 'api'; import { useConfig } from 'contexts/Config'; import AlertModal from 'components/AlertModal'; @@ -42,15 +43,6 @@ const SliderForks = styled.div` 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, @@ -61,6 +53,7 @@ function InstanceListItem({ rowIndex, }) { const { me = {} } = useConfig(); + const { id } = useParams(); const [forks, setForks] = useState( computeForks( instance.mem_capacity, @@ -68,7 +61,6 @@ function InstanceListItem({ instance.capacity_adjustment ) ); - const { id } = useParams(); const labelId = `check-action-${instance.id}`; @@ -147,8 +139,7 @@ function InstanceListItem({ - {instance.jobs_running} - {instance.jobs_total} + {instance.node_type}
{t`CPU ${instance.cpu_capacity}`}
@@ -197,12 +188,23 @@ function InstanceListItem({ - + + diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js index 77c7a92797..614e3ad9e0 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.test.js @@ -274,9 +274,8 @@ describe('', () => { ); }); expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true); - expect(wrapper.find('Detail[label="Node Type"]').prop('value')).toBe( - 'hybrid' - ); + expect(wrapper.find('Detail[label="Running Jobs"]').prop('value')).toBe(0); + expect(wrapper.find('Detail[label="Total Jobs"]').prop('value')).toBe(68); expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe( 'Auto' ); diff --git a/awx/ui/src/screens/Instances/Instance.js b/awx/ui/src/screens/Instances/Instance.js new file mode 100644 index 0000000000..585315d946 --- /dev/null +++ b/awx/ui/src/screens/Instances/Instance.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { t } from '@lingui/macro'; + +import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; +import ContentError from 'components/ContentError'; +import RoutedTabs from 'components/RoutedTabs'; +import InstanceDetail from './InstanceDetail'; + +function Instance({ setBreadcrumb }) { + const match = useRouteMatch(); + const tabsArray = [ + { + name: ( + <> + + {t`Back to Instances`} + + ), + link: `/instances`, + id: 99, + }, + { name: t`Details`, link: `${match.url}/details`, id: 0 }, + ]; + + return ( + + + + + + + + + + + {match.params.id && ( + + {t`View Instance Details`} + + )} + + + + + + ); +} + +export default Instance; diff --git a/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js new file mode 100644 index 0000000000..ddfd8845f8 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js @@ -0,0 +1,256 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { useParams } from 'react-router-dom'; +import { t, Plural } from '@lingui/macro'; +import { + Button, + Progress, + ProgressMeasureLocation, + ProgressSize, + CodeBlock, + CodeBlockCode, + Tooltip, + Slider, +} from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { useConfig } from 'contexts/Config'; +import { InstancesAPI } from 'api'; +import useDebounce from 'hooks/useDebounce'; +import AlertModal from 'components/AlertModal'; +import ErrorDetail from 'components/ErrorDetail'; +import InstanceToggle from 'components/InstanceToggle'; +import { CardBody, CardActionsRow } from 'components/Card'; +import { formatDateString } from 'util/dates'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import { Detail, DetailList } from 'components/DetailList'; +import StatusLabel from 'components/StatusLabel'; +import useRequest, { 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 InstanceDetail({ setBreadcrumb }) { + const { me = {} } = useConfig(); + const { id } = useParams(); + const [forks, setForks] = useState(); + + const [healthCheck, setHealthCheck] = useState({}); + + const { + isLoading, + error: contentError, + request: fetchDetails, + result: instance, + } = useRequest( + useCallback(async () => { + const { data: details } = await InstancesAPI.readDetail(id); + + if (details.node_type !== 'hop') { + const { data: healthCheckData } = + await InstancesAPI.readHealthCheckDetail(id); + setHealthCheck(healthCheckData); + } + + setForks( + computeForks( + details.mem_capacity, + details.cpu_capacity, + details.capacity_adjustment + ) + ); + return details; + }, [id]), + {} + ); + useEffect(() => { + fetchDetails(); + }, [fetchDetails]); + + useEffect(() => { + if (instance) { + setBreadcrumb(instance); + } + }, [instance, setBreadcrumb]); + + const { error: healthCheckError, request: fetchHealthCheck } = useRequest( + useCallback(async () => { + const { data } = await InstancesAPI.healthCheck(id); + setHealthCheck(data); + }, [id]) + ); + + const { error: updateInstanceError, request: updateInstance } = useRequest( + useCallback( + async (values) => { + await InstancesAPI.update(id, values); + }, + [id] + ) + ); + + 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( + updateInstanceError || healthCheckError + ); + if (contentError) { + return ; + } + if (isLoading) { + return ; + } + const isHopNode = instance.node_type === 'hop'; + return ( + + + + + } + /> + + {!isHopNode && ( + <> + + + + + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+
+ } + /> + + ) : ( + {t`Unavailable`} + ) + } + /> + + )} + {healthCheck?.errors && ( + + {healthCheck?.errors} + + } + /> + )} + + {!isHopNode && ( + + + + + + + )} + + {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..782fcdd187 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.js @@ -0,0 +1,189 @@ +import React, { useCallback, useEffect } from 'react'; +import { t } from '@lingui/macro'; +import { useLocation } from 'react-router-dom'; +import 'styled-components/macro'; +import { PageSection, Card } from '@patternfly/react-core'; + +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 HealthCheckButton from 'components/HealthCheckButton'; +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.filter((i) => i.node_type !== 'hop')); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const { + error: healthCheckError, + request: fetchHealthCheck, + isLoading: isHealthCheckLoading, + } = useRequest( + useCallback(async () => { + await Promise.all( + selected + .filter(({ node_type }) => node_type !== 'hop') + .map(({ id }) => InstancesAPI.healthCheck(id)) + ); + fetchInstances(); + }, [selected, fetchInstances]) + ); + const handleHealthCheck = async () => { + await fetchHealthCheck(); + clearSelected(); + }; + const { error, dismissError } = useDismissableError(healthCheckError); + + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(instances); + 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..f8a3eca7d6 --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js @@ -0,0 +1,193 @@ +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(); + 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); + }); +}); 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..fc89bdd3fc --- /dev/null +++ b/awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js @@ -0,0 +1,252 @@ +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 computeForks from 'util/computeForks'; +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 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 }); + }; + const isHopNode = instance.node_type === 'hop'; + return ( + <> + + {isHopNode ? ( + + ) : ( + + )} + + + + {instance.hostname} + + + + + + {t`Last Health Check`} +   + {formatDateString( + instance.last_health_check ?? instance.last_seen + )} + + } + > + + + + + {instance.node_type} + {!isHopNode && ( + <> + + +
{t`CPU ${instance.cpu_capacity}`}
+ +
+ +
+ +
+
{t`RAM ${instance.mem_capacity}`}
+
+ + + + {usedCapacity(instance)} + + + + + + + + + )} + + {!isHopNode && ( + + + + + + + + + + + + + + )} + {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..271c9da420 --- /dev/null +++ b/awx/ui/src/screens/Instances/Instances.js @@ -0,0 +1,40 @@ +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}`]: `${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'; diff --git a/awx/ui/src/util/computeForks.js b/awx/ui/src/util/computeForks.js new file mode 100644 index 0000000000..eccc98d502 --- /dev/null +++ b/awx/ui/src/util/computeForks.js @@ -0,0 +1,12 @@ +export default function computeForks( + memCapacity, + cpuCapacity, + selectedCapacityAdjustment +) { + const minCapacity = Math.min(memCapacity, cpuCapacity); + const maxCapacity = Math.max(memCapacity, cpuCapacity); + + return Math.floor( + minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment + ); +}