Merge pull request #11090 from AlexSCorey/10952-AddHealthCheckOnInstancesList

Adds Health Check functionality to instance list
This commit is contained in:
Sarah Akus 2021-09-21 16:38:22 -04:00 committed by GitHub
commit b41f90e7d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 175 additions and 30 deletions

View File

@ -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/`);
}

View File

@ -45,7 +45,7 @@ function InstanceToggle({ className, fetchInstances, instance, onToggle }) {
return (
<>
<Tooltip
content={t`Set the instance online or offline. If offline, jobs will not be assigned to this instance.`}
content={t`Set the instance enabled or disabled. If disabled, jobs will not be assigned to this instance.`}
position="top"
>
<Switch

View File

@ -118,7 +118,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
useCallback(async () => {
const { data } = await InstancesAPI.createHealthCheck(instanceId);
const { data } = await InstancesAPI.healthCheck(instanceId);
setHealthCheck(data);
}, [instanceId])
);

View File

@ -298,7 +298,7 @@ describe('<InstanceDetails/>', () => {
});
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('<InstanceDetails/>', () => {
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('<InstanceDetails/>', () => {
});
test('Should handle api error for health check', async () => {
InstancesAPI.createHealthCheck.mockRejectedValue(
InstancesAPI.healthCheck.mockRejectedValue(
new Error({
response: {
config: {
@ -392,7 +392,7 @@ describe('<InstanceDetails/>', () => {
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);

View File

@ -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 (
<>
<PaginatedTable
@ -178,6 +190,8 @@ function InstanceList() {
{...props}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
@ -198,6 +212,28 @@ function InstanceList() {
)}
modalTitle={t`Disassociate instance from instance group?`}
/>,
<Tooltip
content={
selected.length ? (
<Plural
value={selected.length}
one="Click to run a health check on the selected instance."
other="Click to run a health check on the selected instances."
/>
) : (
t`Select an instance to run a health check.`
)
}
>
<div>
<Button
isDisabled={!canAdd || !selected.length}
variant="secondary"
ouiaId="health-check"
onClick={fetchHealthCheck}
>{t`Health Check`}</Button>
</div>
</Tooltip>,
]}
emptyStateControls={
canAdd ? (
@ -210,10 +246,9 @@ function InstanceList() {
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
<HeaderCell>{t`Node Type`}</HeaderCell>
<HeaderCell>{t`Policy Type`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell>{t`Running Jobs`}</HeaderCell>
<HeaderCell>{t`Total Jobs`}</HeaderCell>
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>
@ -223,6 +258,8 @@ function InstanceList() {
}
renderRow={(instance, index) => (
<InstanceListItem
isExpanded={expanded.some((row) => 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.`}
<ErrorDetail error={error} />
</AlertModal>
)}

View File

@ -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('<InstanceList/>', () => {
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);
});
});

View File

@ -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 (
<>
<Tr id={`instance-row-${instance.id}`}>
<Td
expand={{
rowIndex,
isExpanded,
onToggle: onExpand,
}}
/>
<Td
select={{
rowIndex,
@ -117,12 +129,17 @@ function InstanceListItem({
/>
<Td id={labelId} dataLabel={t`Name`}>
<Link to={`/instance_groups/${id}/instances/${instance.id}/details`}>
{instance.hostname}
<b>{instance.hostname}</b>
</Link>
</Td>
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
<Td dataLabel={t`Policy Type`}>
{instance.managed_by_policy ? t`Auto` : t`Manual`}
<Td dataLabel={t`Status`}>
<Tooltip
content={t`Last Health Check ${formatDateString(
instance.last_health_check
)}`}
>
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
</Tooltip>
</Td>
<Td dataLabel={t`Running Jobs`}>{instance.jobs_running}</Td>
<Td dataLabel={t`Total Jobs`}>{instance.jobs_total}</Td>
@ -166,6 +183,24 @@ function InstanceListItem({
</ActionItem>
</ActionsTd>
</Tr>
<Tr isExpanded={isExpanded}>
<Td colSpan={2} />
<Td colSpan={7}>
<ExpandableRowContent>
<DetailList>
<Detail label={t`Node Type`} value={instance.node_type} />
<Detail
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail
label={t`Last Health Check`}
value={formatDateString(instance.last_health_check)}
/>
</DetailList>
</ExpandableRowContent>
</Td>
</Tr>
{updateError && (
<AlertModal
variant="error"

View File

@ -36,6 +36,7 @@ const instance = [
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,
@ -58,6 +59,7 @@ const instance = [
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,
@ -162,10 +164,8 @@ describe('<InstanceListItem/>', () => {
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('<InstanceListItem/>', () => {
</table>
);
});
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('<InstanceListItem/>', () => {
</table>
);
});
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('<InstanceListItem/>', () => {
});
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(
<table>
<tbody>
<InstanceListItem
instance={instance[0]}
onSelect={onSelect}
fetchInstances={() => {}}
isExpanded
/>
</tbody>
</table>
);
});
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');
});
});