Adds the Instance Details view with the health check functionality

This commit is contained in:
Alex Corey 2021-09-10 15:54:51 -04:00
parent 1f34d4c134
commit eeb0feabc0
11 changed files with 870 additions and 5 deletions

View File

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

View File

@ -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',

View File

@ -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;

View File

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

View File

@ -0,0 +1 @@
export { default } from './InstanceDetails';

View File

@ -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

View File

@ -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}`,

View File

@ -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`}>

View File

@ -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');

View 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;

View File

@ -1,2 +1,3 @@
export { default as InstanceList } from './InstanceList';
export { default as InstanceListItem } from './InstanceListItem';
export { default as Instances } from './Instances';