Add health check toast notification for Instance list and detail views.

This commit is contained in:
Kia Lam
2022-09-13 08:53:31 -07:00
committed by Jeff Bradberry
parent 0510978516
commit 4a41098b24
7 changed files with 226 additions and 277 deletions

View File

@@ -1,7 +1,15 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Alert, Button, AlertActionCloseButton } from '@patternfly/react-core'; import {
Alert as PFAlert,
Button,
AlertActionCloseButton,
} from '@patternfly/react-core';
import styled from 'styled-components';
const Alert = styled(PFAlert)`
z-index: 1;
`;
function HealthCheckAlert({ onSetHealthCheckAlert }) { function HealthCheckAlert({ onSetHealthCheckAlert }) {
return ( return (
<Alert <Alert
@@ -15,7 +23,7 @@ function HealthCheckAlert({ onSetHealthCheckAlert }) {
<Button <Button
variant="link" variant="link"
isInline isInline
onClick={() => window.location.reload(false)} onClick={() => window.location.reload()}
>{t`Reload`}</Button> >{t`Reload`}</Button>
</> </>
} }

View File

@@ -28,6 +28,7 @@ import RoutedTabs from 'components/RoutedTabs';
import ContentError from 'components/ContentError'; import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading'; import ContentLoading from 'components/ContentLoading';
import { Detail, DetailList } from 'components/DetailList'; import { Detail, DetailList } from 'components/DetailList';
import HealthCheckAlert from 'components/HealthCheckAlert';
import StatusLabel from 'components/StatusLabel'; import StatusLabel from 'components/StatusLabel';
import useRequest, { import useRequest, {
useDeleteItems, useDeleteItems,
@@ -66,6 +67,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
const history = useHistory(); const history = useHistory();
const [healthCheck, setHealthCheck] = useState({}); const [healthCheck, setHealthCheck] = useState({});
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const [forks, setForks] = useState(); const [forks, setForks] = useState();
const { const {
@@ -79,7 +81,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
data: { results }, data: { results },
} = await InstanceGroupsAPI.readInstances(instanceGroup.id); } = await InstanceGroupsAPI.readInstances(instanceGroup.id);
let instanceDetails; let instanceDetails;
let healthCheckDetails;
const isAssociated = results.some( const isAssociated = results.some(
({ id: instId }) => instId === parseInt(instanceId, 10) ({ id: instId }) => instId === parseInt(instanceId, 10)
); );
@@ -92,7 +93,7 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
]); ]);
instanceDetails = details; instanceDetails = details;
healthCheckDetails = healthCheckData; setHealthCheck(healthCheckData);
} else { } else {
throw new Error( throw new Error(
`This instance is not associated with this instance group` `This instance is not associated with this instance group`
@@ -100,7 +101,6 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
} }
setBreadcrumb(instanceGroup, instanceDetails); setBreadcrumb(instanceGroup, instanceDetails);
setHealthCheck(healthCheckDetails);
setForks( setForks(
computeForks( computeForks(
instanceDetails.mem_capacity, instanceDetails.mem_capacity,
@@ -121,8 +121,12 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
request: fetchHealthCheck, request: fetchHealthCheck,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await InstancesAPI.healthCheck(instanceId); const { status } = await InstancesAPI.healthCheck(instanceId);
const { data } = await InstancesAPI.readHealthCheckDetail(instanceId);
setHealthCheck(data); setHealthCheck(data);
if (status === 200) {
setShowHealthCheckAlert(true);
}
}, [instanceId]) }, [instanceId])
); );
@@ -188,6 +192,9 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
return ( return (
<> <>
<RoutedTabs tabsArray={tabsArray} /> <RoutedTabs tabsArray={tabsArray} />
{showHealthCheckAlert ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<CardBody> <CardBody>
<DetailList gutter="sm"> <DetailList gutter="sm">
<Detail <Detail

View File

@@ -298,58 +298,6 @@ describe('<InstanceDetails/>', () => {
expect(InstancesAPI.readDetail).not.toBeCalled(); expect(InstancesAPI.readDetail).not.toBeCalled();
}); });
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,
},
});
InstanceGroupsAPI.readInstances.mockResolvedValue({
data: {
results: [
{
id: 1,
},
{
id: 2,
},
],
},
});
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
me: { is_superuser: true },
}));
await act(async () => {
wrapper = mountWithContexts(
<InstanceDetails
instanceGroup={instanceGroup}
setBreadcrumb={() => {}}
/>
);
});
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 () => { test('Should handle api error for health check', async () => {
InstancesAPI.healthCheck.mockRejectedValue( InstancesAPI.healthCheck.mockRejectedValue(
new Error({ new Error({

View File

@@ -23,6 +23,7 @@ import useSelected from 'hooks/useSelected';
import { InstanceGroupsAPI, InstancesAPI } from 'api'; import { InstanceGroupsAPI, InstancesAPI } from 'api';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs'; import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton'; import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton';
import HealthCheckAlert from 'components/HealthCheckAlert';
import InstanceListItem from './InstanceListItem'; import InstanceListItem from './InstanceListItem';
const QS_CONFIG = getQSConfig('instance', { const QS_CONFIG = getQSConfig('instance', {
@@ -33,6 +34,7 @@ const QS_CONFIG = getQSConfig('instance', {
function InstanceList({ instanceGroup }) { function InstanceList({ instanceGroup }) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const location = useLocation(); const location = useLocation();
const { id: instanceGroupId } = useParams(); const { id: instanceGroupId } = useParams();
@@ -86,9 +88,15 @@ function InstanceList({ instanceGroup }) {
isLoading: isHealthCheckLoading, isLoading: isHealthCheckLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id))); const [...response] = await Promise.all(
fetchInstances(); selected
}, [selected, fetchInstances]) .filter(({ node_type }) => node_type !== 'hop')
.map(({ id }) => InstancesAPI.healthCheck(id))
);
if (response) {
setShowHealthCheckAlert(true);
}
}, [selected])
); );
const handleHealthCheck = async () => { const handleHealthCheck = async () => {
@@ -171,6 +179,9 @@ function InstanceList({ instanceGroup }) {
return ( return (
<> <>
{showHealthCheckAlert ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<PaginatedTable <PaginatedTable
contentError={contentError} contentError={contentError}
hasContentLoading={ hasContentLoading={

View File

@@ -32,6 +32,7 @@ import useRequest, {
useDeleteItems, useDeleteItems,
useDismissableError, useDismissableError,
} from 'hooks/useRequest'; } from 'hooks/useRequest';
import HealthCheckAlert from 'components/HealthCheckAlert';
import RemoveInstanceButton from '../Shared/RemoveInstanceButton'; import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
const Unavailable = styled.span` const Unavailable = styled.span`
@@ -66,6 +67,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
const [forks, setForks] = useState(); const [forks, setForks] = useState();
const history = useHistory(); const history = useHistory();
const [healthCheck, setHealthCheck] = useState({}); const [healthCheck, setHealthCheck] = useState({});
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const { const {
isLoading, isLoading,
@@ -119,8 +121,12 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
request: fetchHealthCheck, request: fetchHealthCheck,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await InstancesAPI.healthCheck(id); const { status } = await InstancesAPI.healthCheck(id);
const { data } = await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(data); setHealthCheck(data);
if (status === 200) {
setShowHealthCheckAlert(true);
}
}, [id]) }, [id])
); );
@@ -175,192 +181,197 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
const isHopNode = instance.node_type === 'hop'; const isHopNode = instance.node_type === 'hop';
return ( return (
<CardBody> <>
<DetailList gutter="sm"> {showHealthCheckAlert ? (
<Detail <HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
label={t`Host Name`} ) : null}
value={instance.hostname} <CardBody>
dataCy="instance-detail-name" <DetailList gutter="sm">
/>
<Detail
label={t`Status`}
value={
instance.node_state ? (
<StatusLabel status={instance.node_state} />
) : null
}
/>
<Detail label={t`Node Type`} value={instance.node_type} />
{!isHopNode && (
<>
<Detail
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail label={t`Host`} value={instance.ip_address} />
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
helpText={t`The Instance Groups to which this instance belongs.`}
value={instanceGroups.map((ig) => (
<React.Fragment key={ig.id}>
<Label
color="blue"
isTruncated
render={({ className, content, componentRef }) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
className={className}
innerRef={componentRef}
>
{content}
</Link>
)}
>
{ig.name}
</Label>{' '}
</React.Fragment>
))}
isEmpty={instanceGroups.length === 0}
/>
)}
<Detail
label={t`Last Health Check`}
value={formatDateString(healthCheck?.last_health_check)}
/>
{instance.related?.install_bundle && (
<Detail
label={t`Install Bundle`}
value={
<Tooltip content={t`Click to download bundle`}>
<Button
component="a"
isSmall
href={`${instance.related?.install_bundle}`}
target="_blank"
variant="secondary"
>
<DownloadIcon />
</Button>
</Tooltip>
}
/>
)}
<Detail
label={t`Capacity Adjustment`}
value={
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural value={forks} one="# fork" other="# forks" />
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instance.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
</SliderHolder>
}
/>
<Detail
label={t`Used Capacity`}
value={
instance.enabled ? (
<Progress
title={t`Used capacity`}
value={Math.round(
100 - instance.percent_capacity_remaining
)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
aria-label={t`Used capacity`}
/>
) : (
<Unavailable>{t`Unavailable`}</Unavailable>
)
}
/>
</>
)}
{healthCheck?.errors && (
<Detail <Detail
fullWidth label={t`Host Name`}
label={t`Errors`} value={instance.hostname}
dataCy="instance-detail-name"
/>
<Detail
label={t`Status`}
value={ value={
<CodeBlock> instance.node_state ? (
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode> <StatusLabel status={instance.node_state} />
</CodeBlock> ) : null
} }
/> />
)} <Detail label={t`Node Type`} value={instance.node_type} />
</DetailList> {!isHopNode && (
{!isHopNode && ( <>
<CardActionsRow> <Detail
{me.is_superuser && isK8s && instance.node_type === 'execution' && ( label={t`Policy Type`}
<RemoveInstanceButton value={instance.managed_by_policy ? t`Auto` : t`Manual`}
itemsToRemove={[instance]} />
isK8s={isK8s} <Detail label={t`Host`} value={instance.ip_address} />
onRemove={removeInstances} <Detail label={t`Running Jobs`} value={instance.jobs_running} />
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
helpText={t`The Instance Groups to which this instance belongs.`}
value={instanceGroups.map((ig) => (
<React.Fragment key={ig.id}>
<Label
color="blue"
isTruncated
render={({ className, content, componentRef }) => (
<Link
to={`${buildLinkURL(ig)}${ig.id}/details`}
className={className}
innerRef={componentRef}
>
{content}
</Link>
)}
>
{ig.name}
</Label>{' '}
</React.Fragment>
))}
isEmpty={instanceGroups.length === 0}
/>
)}
<Detail
label={t`Last Health Check`}
value={formatDateString(healthCheck?.last_health_check)}
/>
{instance.related?.install_bundle && (
<Detail
label={t`Install Bundle`}
value={
<Tooltip content={t`Click to download bundle`}>
<Button
component="a"
isSmall
href={`${instance.related?.install_bundle}`}
target="_blank"
variant="secondary"
>
<DownloadIcon />
</Button>
</Tooltip>
}
/>
)}
<Detail
label={t`Capacity Adjustment`}
value={
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural value={forks} one="# fork" other="# forks" />
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instance.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instance.mem_capacity}`}</div>
</SliderHolder>
}
/>
<Detail
label={t`Used Capacity`}
value={
instance.enabled ? (
<Progress
title={t`Used capacity`}
value={Math.round(
100 - instance.percent_capacity_remaining
)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
aria-label={t`Used capacity`}
/>
) : (
<Unavailable>{t`Unavailable`}</Unavailable>
)
}
/>
</>
)}
{healthCheck?.errors && (
<Detail
fullWidth
label={t`Errors`}
value={
<CodeBlock>
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
</CodeBlock>
}
/> />
)} )}
<Tooltip content={t`Run a health check on the instance`}> </DetailList>
<Button {!isHopNode && (
isDisabled={!me.is_superuser || isRunningHealthCheck} <CardActionsRow>
variant="primary" {me.is_superuser && isK8s && instance.node_type === 'execution' && (
ouiaId="health-check-button" <RemoveInstanceButton
onClick={fetchHealthCheck} itemsToRemove={[instance]}
isLoading={isRunningHealthCheck} isK8s={isK8s}
spinnerAriaLabel={t`Running health check`} onRemove={removeInstances}
> />
{t`Run health check`} )}
</Button> <Tooltip content={t`Run a health check on the instance`}>
</Tooltip> <Button
<InstanceToggle isDisabled={!me.is_superuser || isRunningHealthCheck}
css="display: inline-flex;" variant="primary"
fetchInstances={fetchDetails} ouiaId="health-check-button"
instance={instance} onClick={fetchHealthCheck}
/> isLoading={isRunningHealthCheck}
</CardActionsRow> spinnerAriaLabel={t`Running health check`}
)} >
{t`Run health check`}
</Button>
</Tooltip>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
/>
</CardActionsRow>
)}
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}
onClose={dismissError} onClose={dismissError}
title={t`Error!`} title={t`Error!`}
variant="error" variant="error"
> >
{updateInstanceError {updateInstanceError
? t`Failed to update capacity adjustment.` ? t`Failed to update capacity adjustment.`
: t`Failed to disassociate one or more instances.`} : t`Failed to disassociate one or more instances.`}
<ErrorDetail error={error} /> <ErrorDetail error={error} />
</AlertModal> </AlertModal>
)} )}
{removeError && ( {removeError && (
<AlertModal <AlertModal
isOpen={removeError} isOpen={removeError}
variant="error" variant="error"
aria-label={t`Removal Error`} aria-label={t`Removal Error`}
title={t`Error!`} title={t`Error!`}
onClose={clearDeletionError} onClose={clearDeletionError}
> >
{t`Failed to remove one or more instances.`} {t`Failed to remove one or more instances.`}
<ErrorDetail error={removeError} /> <ErrorDetail error={removeError} />
</AlertModal> </AlertModal>
)} )}
</CardBody> </CardBody>
</>
); );
} }

View File

@@ -165,41 +165,6 @@ describe('<InstanceDetail/>', () => {
expect(wrapper.find('InstanceToggle').length).toBe(1); 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(<InstanceDetail setBreadcrumb={() => {}} />);
});
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 () => { test('Should handle api error for health check', async () => {
InstancesAPI.healthCheck.mockRejectedValue( InstancesAPI.healthCheck.mockRejectedValue(
new Error({ new Error({

View File

@@ -93,10 +93,9 @@ function InstanceList() {
if (response) { if (response) {
setShowHealthCheckAlert(true); setShowHealthCheckAlert(true);
} }
return response;
}, [selected]) }, [selected])
); );
const handleHealthCheck = async () => { const handleHealthCheck = async () => {
await fetchHealthCheck(); await fetchHealthCheck();
clearSelected(); clearSelected();