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 { 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 }) {
return (
<Alert
@@ -15,7 +23,7 @@ function HealthCheckAlert({ onSetHealthCheckAlert }) {
<Button
variant="link"
isInline
onClick={() => window.location.reload(false)}
onClick={() => window.location.reload()}
>{t`Reload`}</Button>
</>
}

View File

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

View File

@@ -298,58 +298,6 @@ describe('<InstanceDetails/>', () => {
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 () => {
InstancesAPI.healthCheck.mockRejectedValue(
new Error({

View File

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

View File

@@ -32,6 +32,7 @@ import useRequest, {
useDeleteItems,
useDismissableError,
} from 'hooks/useRequest';
import HealthCheckAlert from 'components/HealthCheckAlert';
import RemoveInstanceButton from '../Shared/RemoveInstanceButton';
const Unavailable = styled.span`
@@ -66,6 +67,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
const [forks, setForks] = useState();
const history = useHistory();
const [healthCheck, setHealthCheck] = useState({});
const [showHealthCheckAlert, setShowHealthCheckAlert] = useState(false);
const {
isLoading,
@@ -119,8 +121,12 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
request: fetchHealthCheck,
} = useRequest(
useCallback(async () => {
const { data } = await InstancesAPI.healthCheck(id);
const { status } = await InstancesAPI.healthCheck(id);
const { data } = await InstancesAPI.readHealthCheckDetail(id);
setHealthCheck(data);
if (status === 200) {
setShowHealthCheckAlert(true);
}
}, [id])
);
@@ -175,192 +181,197 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
const isHopNode = instance.node_type === 'hop';
return (
<CardBody>
<DetailList gutter="sm">
<Detail
label={t`Host Name`}
value={instance.hostname}
dataCy="instance-detail-name"
/>
<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 && (
<>
{showHealthCheckAlert ? (
<HealthCheckAlert onSetHealthCheckAlert={setShowHealthCheckAlert} />
) : null}
<CardBody>
<DetailList gutter="sm">
<Detail
fullWidth
label={t`Errors`}
label={t`Host Name`}
value={instance.hostname}
dataCy="instance-detail-name"
/>
<Detail
label={t`Status`}
value={
<CodeBlock>
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
</CodeBlock>
instance.node_state ? (
<StatusLabel status={instance.node_state} />
) : null
}
/>
)}
</DetailList>
{!isHopNode && (
<CardActionsRow>
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
<RemoveInstanceButton
itemsToRemove={[instance]}
isK8s={isK8s}
onRemove={removeInstances}
<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
fullWidth
label={t`Errors`}
value={
<CodeBlock>
<CodeBlockCode>{healthCheck?.errors}</CodeBlockCode>
</CodeBlock>
}
/>
)}
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
/>
</CardActionsRow>
)}
</DetailList>
{!isHopNode && (
<CardActionsRow>
{me.is_superuser && isK8s && instance.node_type === 'execution' && (
<RemoveInstanceButton
itemsToRemove={[instance]}
isK8s={isK8s}
onRemove={removeInstances}
/>
)}
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={!me.is_superuser || isRunningHealthCheck}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={isRunningHealthCheck}
spinnerAriaLabel={t`Running health check`}
>
{t`Run health check`}
</Button>
</Tooltip>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
/>
</CardActionsRow>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={t`Error!`}
variant="error"
>
{updateInstanceError
? t`Failed to update capacity adjustment.`
: t`Failed to disassociate one or more instances.`}
<ErrorDetail error={error} />
</AlertModal>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={t`Error!`}
variant="error"
>
{updateInstanceError
? t`Failed to update capacity adjustment.`
: t`Failed to disassociate one or more instances.`}
<ErrorDetail error={error} />
</AlertModal>
)}
{removeError && (
<AlertModal
isOpen={removeError}
variant="error"
aria-label={t`Removal Error`}
title={t`Error!`}
onClose={clearDeletionError}
>
{t`Failed to remove one or more instances.`}
<ErrorDetail error={removeError} />
</AlertModal>
)}
</CardBody>
{removeError && (
<AlertModal
isOpen={removeError}
variant="error"
aria-label={t`Removal Error`}
title={t`Error!`}
onClose={clearDeletionError}
>
{t`Failed to remove one or more instances.`}
<ErrorDetail error={removeError} />
</AlertModal>
)}
</CardBody>
</>
);
}

View File

@@ -165,41 +165,6 @@ describe('<InstanceDetail/>', () => {
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 () => {
InstancesAPI.healthCheck.mockRejectedValue(
new Error({

View File

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