mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
Adds the Instance Details view with the health check functionality
This commit is contained in:
parent
1f34d4c134
commit
eeb0feabc0
@ -4,6 +4,17 @@ class Instances extends Base {
|
||||
constructor(http) {
|
||||
super(http);
|
||||
this.baseUrl = '/api/v2/instances/';
|
||||
|
||||
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
|
||||
this.createHealthCheck = this.createHealthCheck.bind(this);
|
||||
}
|
||||
|
||||
createHealthCheck(instanceId) {
|
||||
return this.http.post(`${this.baseUrl}${instanceId}/health_check/`);
|
||||
}
|
||||
|
||||
readHealthCheckDetail(instanceId) {
|
||||
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ const RunningIcon = styled(SyncAltIcon)`
|
||||
const colors = {
|
||||
success: 'green',
|
||||
successful: 'green',
|
||||
healthy: 'green',
|
||||
failed: 'red',
|
||||
error: 'red',
|
||||
running: 'blue',
|
||||
@ -39,6 +40,7 @@ const colors = {
|
||||
};
|
||||
const icons = {
|
||||
success: CheckCircleIcon,
|
||||
healthy: CheckCircleIcon,
|
||||
successful: CheckCircleIcon,
|
||||
failed: ExclamationCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
@ -52,6 +54,7 @@ const icons = {
|
||||
export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||
const upperCaseStatus = {
|
||||
success: t`Success`,
|
||||
healthy: t`Healthy`,
|
||||
successful: t`Successful`,
|
||||
failed: t`Failed`,
|
||||
error: t`Error`,
|
||||
@ -88,6 +91,7 @@ StatusLabel.propTypes = {
|
||||
status: oneOf([
|
||||
'success',
|
||||
'successful',
|
||||
'healthy',
|
||||
'failed',
|
||||
'error',
|
||||
'running',
|
||||
|
||||
@ -0,0 +1,308 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
CodeBlock,
|
||||
CodeBlockCode,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@patternfly/react-core';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import { InstancesAPI, InstanceGroupsAPI } from 'api';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import DisassociateButton from 'components/DisassociateButton';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import RoutedTabs from 'components/RoutedTabs';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import { Detail, DetailList } from 'components/DetailList';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
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 InstanceDetails({ setBreadcrumb, instanceGroup }) {
|
||||
const { me = {} } = useConfig();
|
||||
const { id, instanceId } = useParams();
|
||||
const history = useHistory();
|
||||
|
||||
const [healthCheck, setHealthCheck] = useState({});
|
||||
const [forks, setForks] = useState();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error: contentError,
|
||||
request: fetchDetails,
|
||||
result: { instance },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: { results },
|
||||
} = await InstanceGroupsAPI.readInstances(instanceGroup.id);
|
||||
let instanceDetails;
|
||||
let healthCheckDetails;
|
||||
const isAssociated = results.some(
|
||||
({ id: instId }) => instId === parseInt(instanceId, 10)
|
||||
);
|
||||
|
||||
if (isAssociated) {
|
||||
const [{ data: details }, { data: healthCheckData }] =
|
||||
await Promise.all([
|
||||
InstancesAPI.readDetail(instanceId),
|
||||
InstancesAPI.readHealthCheckDetail(instanceId),
|
||||
]);
|
||||
|
||||
instanceDetails = details;
|
||||
healthCheckDetails = healthCheckData;
|
||||
} else {
|
||||
throw new Error(
|
||||
`This instance is not associated with this instance group`
|
||||
);
|
||||
}
|
||||
|
||||
setBreadcrumb(instanceGroup, instanceDetails);
|
||||
setHealthCheck(healthCheckDetails);
|
||||
setForks(
|
||||
computeForks(
|
||||
instanceDetails.mem_capacity,
|
||||
instanceDetails.cpu_capacity,
|
||||
instanceDetails.capacity_adjustment
|
||||
)
|
||||
);
|
||||
return { instance: instanceDetails };
|
||||
}, [instanceId, setBreadcrumb, instanceGroup]),
|
||||
{ instance: {}, isLoading: true }
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
}, [fetchDetails]);
|
||||
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InstancesAPI.createHealthCheck(instanceId);
|
||||
setHealthCheck(data);
|
||||
}, [instanceId])
|
||||
);
|
||||
|
||||
const {
|
||||
deleteItems: disassociateInstance,
|
||||
deletionError: disassociateError,
|
||||
} = useDeleteItems(
|
||||
useCallback(async () => {
|
||||
await InstanceGroupsAPI.disassociateInstance(
|
||||
instanceGroup.id,
|
||||
instance.id
|
||||
);
|
||||
history.push(`/instance_groups/${instanceGroup.id}/instances`);
|
||||
}, [instanceGroup.id, instance.id, history])
|
||||
);
|
||||
|
||||
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||
useCallback(
|
||||
async (values) => {
|
||||
await InstancesAPI.update(instance.id, values);
|
||||
},
|
||||
[instance]
|
||||
)
|
||||
);
|
||||
|
||||
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(
|
||||
disassociateError || updateInstanceError || healthCheckError
|
||||
);
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{t`Back to Instances`}
|
||||
</>
|
||||
),
|
||||
link: `/instance_groups/${id}/instances`,
|
||||
id: 99,
|
||||
},
|
||||
{
|
||||
name: t`Details`,
|
||||
link: `/instance_groups/${id}/instances/${instanceId}/details`,
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<CardBody>
|
||||
<DetailList gutter="sm">
|
||||
<Detail
|
||||
label={t`Host Name`}
|
||||
value={instance.hostname}
|
||||
dataCy="instance-detail-name"
|
||||
/>
|
||||
<Detail
|
||||
label={t`Status`}
|
||||
value={
|
||||
<StatusLabel status={healthCheck?.errors ? 'error' : 'healthy'} />
|
||||
}
|
||||
/>
|
||||
<Detail
|
||||
label={t`Policy Type`}
|
||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||
/>
|
||||
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
|
||||
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
|
||||
<Detail
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(healthCheck?.last_health_check)}
|
||||
/>
|
||||
<Detail label={t`Node Type`} value={instance.node_type} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
<Tooltip content={t`Run a health check on the instance`}>
|
||||
<Button
|
||||
isDisabled={!me.is_superuser}
|
||||
variant="primary"
|
||||
ouiaId="health-check-button"
|
||||
onClick={fetchHealthCheck}
|
||||
>
|
||||
{t`Health Check`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<DisassociateButton
|
||||
verifyCannotDisassociate={!me.is_superuser}
|
||||
key="disassociate"
|
||||
onDisassociate={disassociateInstance}
|
||||
itemsToDisassociate={[instance]}
|
||||
modalTitle={t`Disassociate instance from instance group?`}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</CardBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstanceDetails;
|
||||
@ -0,0 +1,490 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as ConfigContext from 'contexts/Config';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { InstancesAPI, InstanceGroupsAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import InstanceDetails from './InstanceDetails';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../../hooks/useDebounce');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 2,
|
||||
instanceId: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
const instanceGroup = {
|
||||
id: 2,
|
||||
type: 'instance_group',
|
||||
url: '/api/v2/instance_groups/2/',
|
||||
related: {
|
||||
named_url: '/api/v2/instance_groups/default/',
|
||||
jobs: '/api/v2/instance_groups/2/jobs/',
|
||||
instances: '/api/v2/instance_groups/2/instances/',
|
||||
},
|
||||
name: 'default',
|
||||
created: '2021-09-08T17:10:39.947029Z',
|
||||
modified: '2021-09-08T17:10:39.959187Z',
|
||||
capacity: 38,
|
||||
committed_capacity: 0,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 100.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 0,
|
||||
instances: 3,
|
||||
is_container_group: false,
|
||||
credential: null,
|
||||
policy_instance_percentage: 100,
|
||||
policy_instance_minimum: 0,
|
||||
policy_instance_list: ['receptor-1', 'receptor-2'],
|
||||
pod_spec_override: '',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
describe('<InstanceDetails/>', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
useDebounce.mockImplementation((fn) => fn);
|
||||
|
||||
InstancesAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
related: {
|
||||
named_url: '/api/v2/instances/awx_1/',
|
||||
jobs: '/api/v2/instances/1/jobs/',
|
||||
instance_groups: '/api/v2/instances/1/instance_groups/',
|
||||
health_check: '/api/v2/instances/1/health_check/',
|
||||
},
|
||||
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 () => {
|
||||
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('InstanceDetails')).toHaveLength(1);
|
||||
|
||||
expect(InstanceGroupsAPI.readInstances).toBeCalledWith(2);
|
||||
expect(InstancesAPI.readHealthCheckDetail).toBeCalledWith(1);
|
||||
expect(InstancesAPI.readDetail).toBeCalledWith(1);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='disassociate-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should calculate number of forks when slide changes', async () => {
|
||||
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('InstanceDetails').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 () => {
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceDetails
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='disassociate-button']").prop('isDisabled')
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should display instance toggle', async () => {
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceDetails
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should throw error because intance is not associated with instance group', async () => {
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
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('ContentError')).toHaveLength(1);
|
||||
expect(InstanceGroupsAPI.readInstances).toBeCalledWith(2);
|
||||
expect(InstancesAPI.readHealthCheckDetail).not.toBeCalled();
|
||||
expect(InstancesAPI.readDetail).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('Should make request for Health Check', async () => {
|
||||
InstancesAPI.createHealthCheck.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.createHealthCheck).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.createHealthCheck.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/instances/1/health_check',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
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.createHealthCheck).toBeCalledWith(1);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should call disassociate', async () => {
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceDetails
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="disassociate-button"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('Button[ouiaId="disassociate-modal-confirm"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
|
||||
expect(InstanceGroupsAPI.disassociateInstance).toHaveBeenCalledWith(2, 1);
|
||||
});
|
||||
|
||||
test('Should throw disassociate error', async () => {
|
||||
InstanceGroupsAPI.disassociateInstance.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/instance_groups',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
InstanceGroupsAPI.readInstances.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceDetails
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="disassociate-button"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper
|
||||
.find('Button[ouiaId="disassociate-modal-confirm"]')
|
||||
.prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './InstanceDetails';
|
||||
@ -21,7 +21,7 @@ import JobList from 'components/JobList';
|
||||
|
||||
import InstanceGroupDetails from './InstanceGroupDetails';
|
||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||
import InstanceList from './Instances/InstanceList';
|
||||
import Instances from './Instances/Instances';
|
||||
|
||||
function InstanceGroup({ setBreadcrumb }) {
|
||||
const { id } = useParams();
|
||||
@ -108,7 +108,8 @@ function InstanceGroup({ setBreadcrumb }) {
|
||||
}
|
||||
|
||||
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
|
||||
if (pathname.endsWith('edit')) {
|
||||
|
||||
if (['edit', 'instances/'].some((name) => pathname.includes(name))) {
|
||||
cardHeader = null;
|
||||
}
|
||||
|
||||
@ -139,7 +140,10 @@ function InstanceGroup({ setBreadcrumb }) {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/:id/instances">
|
||||
<InstanceList />
|
||||
<Instances
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/:id/jobs">
|
||||
<JobList
|
||||
|
||||
@ -38,7 +38,7 @@ function InstanceGroups() {
|
||||
'/instance_groups/container_group/add': t`Create new container group`,
|
||||
});
|
||||
|
||||
const buildBreadcrumbConfig = useCallback((instanceGroups) => {
|
||||
const buildBreadcrumbConfig = useCallback((instanceGroups, instance) => {
|
||||
if (!instanceGroups) {
|
||||
return;
|
||||
}
|
||||
@ -49,6 +49,8 @@ function InstanceGroups() {
|
||||
|
||||
[`/instance_groups/${instanceGroups.id}/details`]: t`Details`,
|
||||
[`/instance_groups/${instanceGroups.id}/instances`]: t`Instances`,
|
||||
[`/instance_groups/${instanceGroups.id}/instances/${instance?.id}`]: `${instance?.hostname}`,
|
||||
[`/instance_groups/${instanceGroups.id}/instances/${instance?.id}/details`]: t`Instance details`,
|
||||
[`/instance_groups/${instanceGroups.id}/jobs`]: t`Jobs`,
|
||||
[`/instance_groups/${instanceGroups.id}/edit`]: t`Edit details`,
|
||||
[`/instance_groups/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { bool, func } from 'prop-types';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
@ -62,6 +63,8 @@ function InstanceListItem({
|
||||
instance.capacity_adjustment
|
||||
)
|
||||
);
|
||||
const { id } = useParams();
|
||||
|
||||
const labelId = `check-action-${instance.id}`;
|
||||
|
||||
function usedCapacity(item) {
|
||||
@ -113,7 +116,9 @@ function InstanceListItem({
|
||||
dataLabel={t`Selected`}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={t`Name`}>
|
||||
{instance.hostname}
|
||||
<Link to={`/instance_groups/${id}/instances/${instance.id}/details`}>
|
||||
{instance.hostname}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
||||
<Td dataLabel={t`Policy Type`}>
|
||||
|
||||
@ -10,6 +10,12 @@ 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,
|
||||
@ -153,6 +159,9 @@ describe('<InstanceListItem/>', () => {
|
||||
</table>
|
||||
);
|
||||
});
|
||||
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('Progress').prop('value')).toBe(40);
|
||||
expect(wrapper.find('Td').at(2).text()).toBe('hybrid');
|
||||
|
||||
30
awx/ui/src/screens/InstanceGroup/Instances/Instances.js
Normal file
30
awx/ui/src/screens/InstanceGroup/Instances/Instances.js
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import InstanceList from './InstanceList';
|
||||
import InstanceDetails from '../InstanceDetails';
|
||||
|
||||
function Instances({ setBreadcrumb, instanceGroup }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/instance_groups/:id/instances/:instanceId"
|
||||
to="/instance_groups/:id/instances/:instanceId/details"
|
||||
exact
|
||||
/>
|
||||
<Route
|
||||
key="details"
|
||||
path="/instance_groups/:id/instances/:instanceId/details"
|
||||
>
|
||||
<InstanceDetails
|
||||
instanceGroup={instanceGroup}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="instanceList" path="/instance_groups/:id/instances">
|
||||
<InstanceList />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default Instances;
|
||||
@ -1,2 +1,3 @@
|
||||
export { default as InstanceList } from './InstanceList';
|
||||
export { default as InstanceListItem } from './InstanceListItem';
|
||||
export { default as Instances } from './Instances';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user