mirror of
https://github.com/ansible/awx.git
synced 2026-02-21 05:00:07 -03:30
Adds the Instance Details view with the health check functionality
This commit is contained in:
@@ -4,6 +4,17 @@ class Instances extends Base {
|
|||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/instances/';
|
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 = {
|
const colors = {
|
||||||
success: 'green',
|
success: 'green',
|
||||||
successful: 'green',
|
successful: 'green',
|
||||||
|
healthy: 'green',
|
||||||
failed: 'red',
|
failed: 'red',
|
||||||
error: 'red',
|
error: 'red',
|
||||||
running: 'blue',
|
running: 'blue',
|
||||||
@@ -39,6 +40,7 @@ const colors = {
|
|||||||
};
|
};
|
||||||
const icons = {
|
const icons = {
|
||||||
success: CheckCircleIcon,
|
success: CheckCircleIcon,
|
||||||
|
healthy: CheckCircleIcon,
|
||||||
successful: CheckCircleIcon,
|
successful: CheckCircleIcon,
|
||||||
failed: ExclamationCircleIcon,
|
failed: ExclamationCircleIcon,
|
||||||
error: ExclamationCircleIcon,
|
error: ExclamationCircleIcon,
|
||||||
@@ -52,6 +54,7 @@ const icons = {
|
|||||||
export default function StatusLabel({ status, tooltipContent = '' }) {
|
export default function StatusLabel({ status, tooltipContent = '' }) {
|
||||||
const upperCaseStatus = {
|
const upperCaseStatus = {
|
||||||
success: t`Success`,
|
success: t`Success`,
|
||||||
|
healthy: t`Healthy`,
|
||||||
successful: t`Successful`,
|
successful: t`Successful`,
|
||||||
failed: t`Failed`,
|
failed: t`Failed`,
|
||||||
error: t`Error`,
|
error: t`Error`,
|
||||||
@@ -88,6 +91,7 @@ StatusLabel.propTypes = {
|
|||||||
status: oneOf([
|
status: oneOf([
|
||||||
'success',
|
'success',
|
||||||
'successful',
|
'successful',
|
||||||
|
'healthy',
|
||||||
'failed',
|
'failed',
|
||||||
'error',
|
'error',
|
||||||
'running',
|
'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 InstanceGroupDetails from './InstanceGroupDetails';
|
||||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||||
import InstanceList from './Instances/InstanceList';
|
import Instances from './Instances/Instances';
|
||||||
|
|
||||||
function InstanceGroup({ setBreadcrumb }) {
|
function InstanceGroup({ setBreadcrumb }) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -108,7 +108,8 @@ function InstanceGroup({ setBreadcrumb }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
|
let cardHeader = <RoutedTabs tabsArray={tabsArray} />;
|
||||||
if (pathname.endsWith('edit')) {
|
|
||||||
|
if (['edit', 'instances/'].some((name) => pathname.includes(name))) {
|
||||||
cardHeader = null;
|
cardHeader = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +140,10 @@ function InstanceGroup({ setBreadcrumb }) {
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/instances">
|
<Route path="/instance_groups/:id/instances">
|
||||||
<InstanceList />
|
<Instances
|
||||||
|
instanceGroup={instanceGroup}
|
||||||
|
setBreadcrumb={setBreadcrumb}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/jobs">
|
<Route path="/instance_groups/:id/jobs">
|
||||||
<JobList
|
<JobList
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function InstanceGroups() {
|
|||||||
'/instance_groups/container_group/add': t`Create new container group`,
|
'/instance_groups/container_group/add': t`Create new container group`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildBreadcrumbConfig = useCallback((instanceGroups) => {
|
const buildBreadcrumbConfig = useCallback((instanceGroups, instance) => {
|
||||||
if (!instanceGroups) {
|
if (!instanceGroups) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,8 @@ function InstanceGroups() {
|
|||||||
|
|
||||||
[`/instance_groups/${instanceGroups.id}/details`]: t`Details`,
|
[`/instance_groups/${instanceGroups.id}/details`]: t`Details`,
|
||||||
[`/instance_groups/${instanceGroups.id}/instances`]: t`Instances`,
|
[`/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}/jobs`]: t`Jobs`,
|
||||||
[`/instance_groups/${instanceGroups.id}/edit`]: t`Edit details`,
|
[`/instance_groups/${instanceGroups.id}/edit`]: t`Edit details`,
|
||||||
[`/instance_groups/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
[`/instance_groups/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { bool, func } from 'prop-types';
|
import { bool, func } from 'prop-types';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -62,6 +63,8 @@ function InstanceListItem({
|
|||||||
instance.capacity_adjustment
|
instance.capacity_adjustment
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
const labelId = `check-action-${instance.id}`;
|
const labelId = `check-action-${instance.id}`;
|
||||||
|
|
||||||
function usedCapacity(item) {
|
function usedCapacity(item) {
|
||||||
@@ -113,7 +116,9 @@ function InstanceListItem({
|
|||||||
dataLabel={t`Selected`}
|
dataLabel={t`Selected`}
|
||||||
/>
|
/>
|
||||||
<Td id={labelId} dataLabel={t`Name`}>
|
<Td id={labelId} dataLabel={t`Name`}>
|
||||||
{instance.hostname}
|
<Link to={`/instance_groups/${id}/instances/${instance.id}/details`}>
|
||||||
|
{instance.hostname}
|
||||||
|
</Link>
|
||||||
</Td>
|
</Td>
|
||||||
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
||||||
<Td dataLabel={t`Policy Type`}>
|
<Td dataLabel={t`Policy Type`}>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import InstanceListItem from './InstanceListItem';
|
|||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
jest.mock('../../../hooks/useDebounce');
|
jest.mock('../../../hooks/useDebounce');
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: () => ({
|
||||||
|
id: 1,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
const instance = [
|
const instance = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -153,6 +159,9 @@ describe('<InstanceListItem/>', () => {
|
|||||||
</table>
|
</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('Td').at(1).text()).toBe('awx');
|
||||||
expect(wrapper.find('Progress').prop('value')).toBe(40);
|
expect(wrapper.find('Progress').prop('value')).toBe(40);
|
||||||
expect(wrapper.find('Td').at(2).text()).toBe('hybrid');
|
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 InstanceList } from './InstanceList';
|
||||||
export { default as InstanceListItem } from './InstanceListItem';
|
export { default as InstanceListItem } from './InstanceListItem';
|
||||||
|
export { default as Instances } from './Instances';
|
||||||
|
|||||||
Reference in New Issue
Block a user