diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js index 43608cae04..6109f5cbc2 100644 --- a/awx/ui/src/api/models/Instances.js +++ b/awx/ui/src/api/models/Instances.js @@ -6,10 +6,10 @@ class Instances extends Base { this.baseUrl = '/api/v2/instances/'; this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this); - this.createHealthCheck = this.createHealthCheck.bind(this); + this.healthCheck = this.healthCheck.bind(this); } - createHealthCheck(instanceId) { + healthCheck(instanceId) { return this.http.post(`${this.baseUrl}${instanceId}/health_check/`); } diff --git a/awx/ui/src/components/InstanceToggle/InstanceToggle.js b/awx/ui/src/components/InstanceToggle/InstanceToggle.js index a7caf47b0d..53d4e8b2bb 100644 --- a/awx/ui/src/components/InstanceToggle/InstanceToggle.js +++ b/awx/ui/src/components/InstanceToggle/InstanceToggle.js @@ -45,7 +45,7 @@ function InstanceToggle({ className, fetchInstances, instance, onToggle }) { return ( <> { - const { data } = await InstancesAPI.createHealthCheck(instanceId); + const { data } = await InstancesAPI.healthCheck(instanceId); setHealthCheck(data); }, [instanceId]) ); diff --git a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js index 48a215f969..a9a0c10b5d 100644 --- a/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js +++ b/awx/ui/src/screens/InstanceGroup/InstanceDetails/InstanceDetails.test.js @@ -298,7 +298,7 @@ describe('', () => { }); test('Should make request for Health Check', async () => { - InstancesAPI.createHealthCheck.mockResolvedValue({ + InstancesAPI.healthCheck.mockResolvedValue({ data: { uuid: '00000000-0000-0000-0000-000000000000', hostname: 'awx_1', @@ -342,7 +342,7 @@ describe('', () => { await act(async () => { wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); }); - expect(InstancesAPI.createHealthCheck).toBeCalledWith(1); + expect(InstancesAPI.healthCheck).toBeCalledWith(1); wrapper.update(); expect( wrapper.find("Detail[label='Last Health Check']").prop('value') @@ -350,7 +350,7 @@ describe('', () => { }); test('Should handle api error for health check', async () => { - InstancesAPI.createHealthCheck.mockRejectedValue( + InstancesAPI.healthCheck.mockRejectedValue( new Error({ response: { config: { @@ -392,7 +392,7 @@ describe('', () => { await act(async () => { wrapper.find("Button[ouiaId='health-check-button']").prop('onClick')(); }); - expect(InstancesAPI.createHealthCheck).toBeCalledWith(1); + 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/InstanceGroup/Instances/InstanceList.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js index 62d3de8e83..f87c6cae62 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.js @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { t } from '@lingui/macro'; +import { Plural, t } from '@lingui/macro'; import { useLocation, useParams } from 'react-router-dom'; import 'styled-components/macro'; +import useExpanded from 'hooks/useExpanded'; import DataListToolbar from 'components/DataListToolbar'; import PaginatedTable, { HeaderRow, @@ -24,6 +25,7 @@ import useSelected from 'hooks/useSelected'; import { InstanceGroupsAPI, InstancesAPI } from 'api'; import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; +import { Button, Tooltip } from '@patternfly/react-core'; import InstanceListItem from './InstanceListItem'; const QS_CONFIG = getQSConfig('instance', { @@ -81,6 +83,13 @@ function InstanceList() { fetchInstances(); }, [fetchInstances]); + const { error: healthCheckError, request: fetchHealthCheck } = useRequest( + useCallback(async () => { + await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id))); + fetchInstances(); + }, [selected, fetchInstances]) + ); + const { isLoading: isDisassociateLoading, deleteItems: disassociateInstances, @@ -129,7 +138,7 @@ function InstanceList() { }; const { error, dismissError } = useDismissableError( - associateError || disassociateError + associateError || disassociateError || healthCheckError ); const canAdd = @@ -148,6 +157,9 @@ function InstanceList() { [instanceGroupId] ); + const { expanded, isAllExpanded, handleExpand, expandAll } = + useExpanded(instances); + return ( <> , + + ) : ( + t`Select an instance to run a health check.` + ) + } + > + + {t`Health Check`} + + , ]} emptyStateControls={ canAdd ? ( @@ -210,10 +246,9 @@ function InstanceList() { /> )} headerRow={ - + {t`Name`} - {t`Node Type`} - {t`Policy Type`} + {t`Status`} {t`Running Jobs`} {t`Total Jobs`} {t`Capacity Adjustment`} @@ -223,6 +258,8 @@ function InstanceList() { } renderRow={(instance, index) => ( row.id === instance.id)} + onExpand={() => handleExpand(instance)} key={instance.id} value={instance.hostname} instance={instance} @@ -252,9 +289,11 @@ function InstanceList() { title={t`Error!`} variant="error" > - {associateError - ? t`Failed to associate.` - : t`Failed to disassociate one or more instances.`} + {associateError && t`Failed to associate.`} + {disassociateError && + t`Failed to disassociate one or more instances.`} + {healthCheckError && + t`Failed to run a health check on one or more instances.`} )} diff --git a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.test.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.test.js index 5355f7b939..82c60afbb5 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.test.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceList.test.js @@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils'; import { Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import { InstanceGroupsAPI } from 'api'; +import { InstancesAPI, InstanceGroupsAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -152,4 +152,49 @@ describe('', () => { wrapper.find('ModalBoxCloseButton').simulate('click'); expect(wrapper.find('AssociateModal').length).toBe(0); }); + test('should run health check', async () => { + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(true); + await act(async () => + wrapper.find('DataListToolbar').prop('onSelectAll')(instances) + ); + wrapper.update(); + expect( + wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled') + ).toBe(false); + 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('DataListToolbar').prop('onSelectAll')(instances) + ); + 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/InstanceGroup/Instances/InstanceListItem.js b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js index a1e072347b..895ff6971d 100644 --- a/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js +++ b/awx/ui/src/screens/InstanceGroup/Instances/InstanceListItem.js @@ -9,11 +9,13 @@ import { ProgressMeasureLocation, ProgressSize, Slider, + Tooltip, } from '@patternfly/react-core'; -import { Tr, Td } from '@patternfly/react-table'; - +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'; @@ -21,6 +23,7 @@ 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); @@ -50,6 +53,8 @@ function computeForks(memCapacity, cpuCapacity, selectedCapacityAdjustment) { function InstanceListItem({ instance, + isExpanded, + onExpand, isSelected, onSelect, fetchInstances, @@ -106,6 +111,13 @@ function InstanceListItem({ return ( <> + - {instance.hostname} + {instance.hostname} - {instance.node_type} - - {instance.managed_by_policy ? t`Auto` : t`Manual`} + + + + {instance.jobs_running} {instance.jobs_total} @@ -166,6 +183,24 @@ function InstanceListItem({ + + + + + + + + + + + + {updateError && ( ', () => { 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('Td').at(2).text()).toBe('awx'); expect(wrapper.find('Progress').prop('value')).toBe(40); - expect(wrapper.find('Td').at(2).text()).toBe('hybrid'); - expect(wrapper.find('Td').at(3).text()).toBe('Auto'); expect( wrapper .find('Td') @@ -198,9 +198,7 @@ describe('', () => { ); }); - expect(wrapper.find('Td').first().prop('select').onSelect).toEqual( - onSelect - ); + expect(wrapper.find('Td').at(1).prop('select').onSelect).toEqual(onSelect); }); test('should disable checkbox', async () => { @@ -218,7 +216,7 @@ describe('', () => { ); }); - expect(wrapper.find('Td').first().prop('select').disable).toEqual(true); + expect(wrapper.find('Td').at(1).prop('select').disable).toEqual(true); }); test('should display instance toggle', () => { @@ -276,4 +274,32 @@ describe('', () => { }); 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="Node Type"]').prop('value')).toBe( + 'hybrid' + ); + 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'); + }); });