mirror of
https://github.com/ansible/awx.git
synced 2026-03-13 15:09:32 -02:30
Adds top level instances list
This commit is contained in:
@@ -56,6 +56,7 @@ function ToolbarAddButton({
|
||||
return (
|
||||
<Button
|
||||
ouiaId={ouiaId}
|
||||
isDisabled={isDisabled}
|
||||
icon={showToggleIndicator ? <CaretDownIcon /> : null}
|
||||
iconPosition={showToggleIndicator ? 'right' : null}
|
||||
variant="primary"
|
||||
|
||||
@@ -8,6 +8,7 @@ import Credentials from 'screens/Credential';
|
||||
import Dashboard from 'screens/Dashboard';
|
||||
import ExecutionEnvironments from 'screens/ExecutionEnvironment';
|
||||
import Hosts from 'screens/Host';
|
||||
import Instances from 'screens/Instances';
|
||||
import InstanceGroups from 'screens/InstanceGroup';
|
||||
import Inventory from 'screens/Inventory';
|
||||
import ManagementJobs from 'screens/ManagementJob';
|
||||
@@ -131,6 +132,11 @@ function getRouteConfig(userProfile = {}) {
|
||||
path: '/instance_groups',
|
||||
screen: InstanceGroups,
|
||||
},
|
||||
{
|
||||
title: <Trans>Instances</Trans>,
|
||||
path: '/instances',
|
||||
screen: Instances,
|
||||
},
|
||||
{
|
||||
title: <Trans>Applications</Trans>,
|
||||
path: '/applications',
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('getRouteConfig', () => {
|
||||
'/notification_templates',
|
||||
'/management_jobs',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
'/settings',
|
||||
@@ -67,6 +68,7 @@ describe('getRouteConfig', () => {
|
||||
'/notification_templates',
|
||||
'/management_jobs',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
'/settings',
|
||||
@@ -93,6 +95,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -120,6 +123,7 @@ describe('getRouteConfig', () => {
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -143,6 +147,7 @@ describe('getRouteConfig', () => {
|
||||
'/users',
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -166,6 +171,7 @@ describe('getRouteConfig', () => {
|
||||
'/users',
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -194,6 +200,7 @@ describe('getRouteConfig', () => {
|
||||
'/teams',
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -223,6 +230,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
@@ -252,6 +260,7 @@ describe('getRouteConfig', () => {
|
||||
'/credential_types',
|
||||
'/notification_templates',
|
||||
'/instance_groups',
|
||||
'/instances',
|
||||
'/applications',
|
||||
'/execution_environments',
|
||||
]);
|
||||
|
||||
@@ -150,6 +150,7 @@ function InstanceList() {
|
||||
mergeParams(params, {
|
||||
...{ not__rampart_groups__id: instanceGroupId },
|
||||
...{ not__node_type: 'control' },
|
||||
...{ not__node_type: 'hop' },
|
||||
})
|
||||
),
|
||||
[instanceGroupId]
|
||||
|
||||
53
awx/ui/src/screens/Instances/Instance.js
Normal file
53
awx/ui/src/screens/Instances/Instance.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom';
|
||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import ContentError from 'components/ContentError';
|
||||
import RoutedTabs from 'components/RoutedTabs';
|
||||
import InstanceDetail from './InstanceDetail';
|
||||
|
||||
function Instance({ setBreadcrumb }) {
|
||||
const match = useRouteMatch();
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{t`Back to Instances`}
|
||||
</>
|
||||
),
|
||||
link: `/instances`,
|
||||
id: 99,
|
||||
},
|
||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<RoutedTabs tabsArray={tabsArray} />
|
||||
<Switch>
|
||||
<Redirect from="/instances/:id" to="/instances/:id/details" exact />
|
||||
<Route path="/instances/:id/details" key="details">
|
||||
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
||||
</Route>
|
||||
,
|
||||
<Route path="*" key="not-found">
|
||||
<ContentError isNotFound>
|
||||
{match.params.id && (
|
||||
<Link to={`/instances/${match.params.id}/details`}>
|
||||
{t`View Instance Details`}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export { Instance as _Instance };
|
||||
export default Instance;
|
||||
259
awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js
Normal file
259
awx/ui/src/screens/Instances/InstanceDetail/InstanceDetail.js
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
CodeBlock,
|
||||
CodeBlockCode,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import { InstancesAPI } from 'api';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import { CardBody, CardActionsRow } from 'components/Card';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import { Detail, DetailList } from 'components/DetailList';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import useRequest, { 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 InstanceDetail({ setBreadcrumb }) {
|
||||
const { me = {} } = useConfig();
|
||||
const { id } = useParams();
|
||||
const [forks, setForks] = useState();
|
||||
|
||||
const [healthCheck, setHealthCheck] = useState({});
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error: contentError,
|
||||
request: fetchDetails,
|
||||
result: instance,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data: details } = await InstancesAPI.readDetail(id);
|
||||
|
||||
if (details.node_type !== 'hop') {
|
||||
const { data: healthCheckData } =
|
||||
await InstancesAPI.readHealthCheckDetail(id);
|
||||
setHealthCheck(healthCheckData);
|
||||
}
|
||||
|
||||
setForks(
|
||||
computeForks(
|
||||
details.mem_capacity,
|
||||
details.cpu_capacity,
|
||||
details.capacity_adjustment
|
||||
)
|
||||
);
|
||||
return details;
|
||||
}, [id]),
|
||||
{}
|
||||
);
|
||||
useEffect(() => {
|
||||
fetchDetails();
|
||||
}, [fetchDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instance) {
|
||||
setBreadcrumb(instance);
|
||||
}
|
||||
}, [instance, setBreadcrumb]);
|
||||
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InstancesAPI.healthCheck(id);
|
||||
setHealthCheck(data);
|
||||
}, [id])
|
||||
);
|
||||
|
||||
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||
useCallback(
|
||||
async (values) => {
|
||||
await InstancesAPI.update(id, values);
|
||||
},
|
||||
[id]
|
||||
)
|
||||
);
|
||||
|
||||
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(
|
||||
updateInstanceError || healthCheckError
|
||||
);
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
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={
|
||||
<StatusLabel status={healthCheck?.errors ? 'error' : 'healthy'} />
|
||||
}
|
||||
/>
|
||||
<Detail label={t`Node Type`} value={instance.node_type} />
|
||||
<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} />
|
||||
{!isHopNode && (
|
||||
<>
|
||||
<Detail
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(healthCheck?.last_health_check)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{!isHopNode && (
|
||||
<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>
|
||||
<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 InstanceDetail;
|
||||
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as ConfigContext from 'contexts/Config';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { InstancesAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import InstanceDetail from './InstanceDetail';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../../hooks/useDebounce');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('<InstanceDetail/>', () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
useDebounce.mockImplementation((fn) => fn);
|
||||
|
||||
InstancesAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
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 () => {
|
||||
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('InstanceDetail')).toHaveLength(1);
|
||||
|
||||
expect(InstancesAPI.readDetail).toBeCalledWith(1);
|
||||
expect(InstancesAPI.readHealthCheckDetail).toBeCalledWith(1);
|
||||
expect(
|
||||
wrapper.find("Button[ouiaId='health-check-button']").prop('isDisabled')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should calculate number of forks when slide changes', async () => {
|
||||
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('InstanceDetail').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 () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: 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(true);
|
||||
});
|
||||
|
||||
test('should display instance toggle', async () => {
|
||||
jest.spyOn(ConfigContext, 'useConfig').mockImplementation(() => ({
|
||||
me: { is_system_auditor: true },
|
||||
}));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceDetail setBreadcrumb={() => {}} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
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({
|
||||
response: {
|
||||
config: {
|
||||
method: 'post',
|
||||
url: '/api/v2/instances/1/health_check',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
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('AlertModal')).toHaveLength(1);
|
||||
expect(wrapper.find('ErrorDetail')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
1
awx/ui/src/screens/Instances/InstanceDetail/index.js
Normal file
1
awx/ui/src/screens/Instances/InstanceDetail/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InstanceDetail';
|
||||
216
awx/ui/src/screens/Instances/InstanceList/InstanceList.js
Normal file
216
awx/ui/src/screens/Instances/InstanceList/InstanceList.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Plural, t } from '@lingui/macro';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import 'styled-components/macro';
|
||||
|
||||
import useExpanded from 'hooks/useExpanded';
|
||||
import DataListToolbar from 'components/DataListToolbar';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
getSearchableKeys,
|
||||
} from 'components/PaginatedTable';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useSelected from 'hooks/useSelected';
|
||||
import { InstancesAPI } from 'api';
|
||||
import { getQSConfig, parseQueryString } from 'util/qs';
|
||||
|
||||
import { Button, Tooltip, PageSection, Card } from '@patternfly/react-core';
|
||||
import InstanceListItem from './InstanceListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig('instance', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'hostname',
|
||||
});
|
||||
|
||||
function InstanceList() {
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
result: { instances, count, relatedSearchableKeys, searchableKeys },
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchInstances,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const [response, responseActions] = await Promise.all([
|
||||
InstancesAPI.read(params),
|
||||
InstancesAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
instances: response.data.results,
|
||||
count: response.data.count,
|
||||
actions: responseActions.data.actions,
|
||||
relatedSearchableKeys: (
|
||||
responseActions?.data?.related_search_fields || []
|
||||
).map((val) => val.slice(0, -8)),
|
||||
searchableKeys: getSearchableKeys(responseActions.data.actions?.GET),
|
||||
};
|
||||
}, [location.search]),
|
||||
{
|
||||
instances: [],
|
||||
count: 0,
|
||||
actions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||
useSelected(instances);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstances();
|
||||
}, [fetchInstances]);
|
||||
|
||||
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
|
||||
useCallback(async () => {
|
||||
await Promise.all(
|
||||
selected
|
||||
.filter(({ node_type }) => node_type !== 'hop')
|
||||
.map(({ id }) => InstancesAPI.healthCheck(id))
|
||||
);
|
||||
fetchInstances();
|
||||
clearSelected();
|
||||
}, [selected, clearSelected, fetchInstances])
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(healthCheckError);
|
||||
|
||||
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
||||
useExpanded(instances);
|
||||
|
||||
const hopNodeSelected = selected.filter(
|
||||
(instance) => instance.node_type === 'hop'
|
||||
).length;
|
||||
|
||||
const buildTooltip = () => {
|
||||
if (hopNodeSelected) {
|
||||
return (
|
||||
<Plural
|
||||
value={hopNodeSelected}
|
||||
one="Cannot run health check on a hop node. Deselect the hop node to run a health check."
|
||||
other="Cannot run health check on hop nodes. Deselect the hop nodes to run health checks."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return selected.length ? (
|
||||
<Plural
|
||||
value={selected.length}
|
||||
one="Click to run a health check on the selected instance."
|
||||
other="Click to run a health check on the selected instances."
|
||||
/>
|
||||
) : (
|
||||
t`Select an instance to run a health check.`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading}
|
||||
items={instances}
|
||||
itemCount={count}
|
||||
pluralizedItemName={t`Instances`}
|
||||
qsConfig={QS_CONFIG}
|
||||
clearSelected={clearSelected}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'hostname__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Node Type`,
|
||||
key: `or__node_type`,
|
||||
options: [
|
||||
[`control`, t`Control`],
|
||||
[`execution`, t`Execution`],
|
||||
[`hybrid`, t`Hybrid`],
|
||||
[`hop`, t`Hop`],
|
||||
],
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: t`Name`,
|
||||
key: 'hostname',
|
||||
},
|
||||
]}
|
||||
renderToolbar={(props) => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={selectAll}
|
||||
isAllExpanded={isAllExpanded}
|
||||
onExpandAll={expandAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
<Tooltip ouiaId="healthCheckTooltip" content={buildTooltip()}>
|
||||
<div>
|
||||
<Button
|
||||
isDisabled={
|
||||
!selected.length || Boolean(hopNodeSelected)
|
||||
}
|
||||
variant="secondary"
|
||||
ouiaId="health-check"
|
||||
onClick={fetchHealthCheck}
|
||||
>{t`Health Check`}</Button>
|
||||
</div>
|
||||
</Tooltip>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
|
||||
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
|
||||
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
|
||||
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
|
||||
<HeaderCell sortKey="capacity_adjustment">{t`Capacity Adjustment`}</HeaderCell>
|
||||
<HeaderCell>{t`Used Capacity`}</HeaderCell>
|
||||
<HeaderCell sortKey="enabled">{t`Actions`}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
renderRow={(instance, index) => (
|
||||
<InstanceListItem
|
||||
isExpanded={expanded.some((row) => row.id === instance.id)}
|
||||
onExpand={() => handleExpand(instance)}
|
||||
key={instance.id}
|
||||
value={instance.hostname}
|
||||
instance={instance}
|
||||
onSelect={() => handleSelect(instance)}
|
||||
isSelected={selected.some((row) => row.id === instance.id)}
|
||||
fetchInstances={fetchInstances}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
onClose={dismissError}
|
||||
title={t`Error!`}
|
||||
variant="error"
|
||||
>
|
||||
{t`Failed to run a health check on one or more instances.`}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstanceList;
|
||||
211
awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js
Normal file
211
awx/ui/src/screens/Instances/InstanceList/InstanceList.test.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
|
||||
import { InstancesAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import InstanceList from './InstanceList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
const instances = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'control',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/2/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'hybrid',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/3/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'execution',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: false,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/4/',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
node_type: 'hop',
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: false,
|
||||
managed_by_policy: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InstanceList/>', () => {
|
||||
let wrapper;
|
||||
|
||||
const options = { data: { actions: { POST: true } } };
|
||||
|
||||
beforeEach(async () => {
|
||||
InstancesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: instances.length,
|
||||
results: instances,
|
||||
},
|
||||
});
|
||||
InstancesAPI.readOptions.mockResolvedValue(options);
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/instances/1'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route path="/instances/:id">
|
||||
<InstanceList />
|
||||
</Route>,
|
||||
{
|
||||
context: {
|
||||
router: { history, route: { location: history.location } },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should have data fetched', () => {
|
||||
expect(wrapper.find('InstanceList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should fetch instances from the api and render them in the list', () => {
|
||||
expect(InstancesAPI.read).toHaveBeenCalled();
|
||||
expect(InstancesAPI.readOptions).toHaveBeenCalled();
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(4);
|
||||
});
|
||||
|
||||
test('should run health check', async () => {
|
||||
// Ensures health check button is disabled on mount
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('DataListToolbar').prop('onSelectAll')(instances)
|
||||
);
|
||||
wrapper.update();
|
||||
|
||||
// Ensures health check button is disabled because a hop node is among
|
||||
// the selected.
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
|
||||
await act(async () =>
|
||||
wrapper.find('input[aria-label="Select row 3"]').prop('onChange')(false)
|
||||
);
|
||||
wrapper.update();
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
|
||||
);
|
||||
expect(InstancesAPI.healthCheck).toBeCalledTimes(3);
|
||||
});
|
||||
test('should render health check error', async () => {
|
||||
InstancesAPI.healthCheck.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'create',
|
||||
url: '/api/v2/instances',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
status: 403,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
await act(async () =>
|
||||
wrapper.find('input[aria-label="Select row 1"]').prop('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(false);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('AlertModal')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Health check button should remain disabled', async () => {
|
||||
await act(async () =>
|
||||
wrapper.find('input[aria-label="Select row 3"]').prop('onChange')(true)
|
||||
);
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('Button[ouiaId="health-check"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
expect(wrapper.find('Tooltip[ouiaId="healthCheckTooltip"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
239
awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js
Normal file
239
awx/ui/src/screens/Instances/InstanceList/InstanceListItem.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bool, func } from 'prop-types';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import 'styled-components/macro';
|
||||
import {
|
||||
Progress,
|
||||
ProgressMeasureLocation,
|
||||
ProgressSize,
|
||||
Slider,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { formatDateString } from 'util/dates';
|
||||
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
|
||||
import InstanceToggle from 'components/InstanceToggle';
|
||||
import StatusLabel from 'components/StatusLabel';
|
||||
import { Instance } from 'types';
|
||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { InstancesAPI } from 'api';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
import AlertModal from 'components/AlertModal';
|
||||
import ErrorDetail from 'components/ErrorDetail';
|
||||
import { Detail, DetailList } from 'components/DetailList';
|
||||
|
||||
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 InstanceListItem({
|
||||
instance,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
isSelected,
|
||||
onSelect,
|
||||
fetchInstances,
|
||||
rowIndex,
|
||||
}) {
|
||||
const { me = {} } = useConfig();
|
||||
const [forks, setForks] = useState(
|
||||
computeForks(
|
||||
instance.mem_capacity,
|
||||
instance.cpu_capacity,
|
||||
instance.capacity_adjustment
|
||||
)
|
||||
);
|
||||
|
||||
const labelId = `check-action-${instance.id}`;
|
||||
|
||||
function usedCapacity(item) {
|
||||
if (item.enabled) {
|
||||
return (
|
||||
<Progress
|
||||
value={Math.round(100 - item.percent_capacity_remaining)}
|
||||
measureLocation={ProgressMeasureLocation.top}
|
||||
size={ProgressSize.sm}
|
||||
title={t`Used capacity`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Unavailable>{t`Unavailable`}</Unavailable>;
|
||||
}
|
||||
|
||||
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||
useCallback(
|
||||
async (values) => {
|
||||
await InstancesAPI.update(instance.id, values);
|
||||
},
|
||||
[instance]
|
||||
)
|
||||
);
|
||||
|
||||
const { error: updateError, dismissError: dismissUpdateError } =
|
||||
useDismissableError(updateInstanceError);
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
id={`instance-row-${instance.id}`}
|
||||
ouiaId={`instance-row-${instance.id}`}
|
||||
>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
isExpanded,
|
||||
onToggle: onExpand,
|
||||
}}
|
||||
/>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={t`Selected`}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={t`Name`}>
|
||||
<Link to={`/instances/${instance.id}/details`}>
|
||||
<b>{instance.hostname}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={t`Status`}>
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
{t`Last Health Check`}
|
||||
|
||||
{formatDateString(instance.last_health_check)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
|
||||
{instance.node_type !== 'hop' && (
|
||||
<Td dataLabel={t`Capacity Adjustment`}>
|
||||
<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>
|
||||
</Td>
|
||||
)}
|
||||
{instance.node_type !== 'hop' && (
|
||||
<Td
|
||||
dataLabel={t`Instance group used capacity`}
|
||||
css="--pf-c-table--cell--MinWidth: 175px;"
|
||||
>
|
||||
{usedCapacity(instance)}
|
||||
</Td>
|
||||
)}
|
||||
{instance.node_type !== 'hop' && (
|
||||
<ActionsTd
|
||||
dataLabel={t`Actions`}
|
||||
css="--pf-c-table--cell--Width: 125px"
|
||||
>
|
||||
<ActionItem visible>
|
||||
<InstanceToggle
|
||||
css="display: inline-flex;"
|
||||
fetchInstances={fetchInstances}
|
||||
instance={instance}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
)}
|
||||
</Tr>
|
||||
<Tr
|
||||
ouiaId={`instance-row-${instance.id}-expanded`}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<Td colSpan={2} />
|
||||
<Td colSpan={7}>
|
||||
<ExpandableRowContent>
|
||||
<DetailList>
|
||||
<Detail value={instance.jobs_running} label={t`Running Jobs`} />
|
||||
<Detail value={instance.jobs_total} label={t`Total Jobs`} />
|
||||
<Detail
|
||||
label={t`Policy Type`}
|
||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||
/>
|
||||
<Detail
|
||||
label={t`Last Health Check`}
|
||||
value={formatDateString(instance.last_health_check)}
|
||||
/>
|
||||
</DetailList>
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
{updateError && (
|
||||
<AlertModal
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
isOpen
|
||||
onClose={dismissUpdateError}
|
||||
>
|
||||
{t`Failed to update capacity adjustment.`}
|
||||
<ErrorDetail error={updateError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
InstanceListItem.prototype = {
|
||||
instance: Instance.isRequired,
|
||||
isSelected: bool.isRequired,
|
||||
onSelect: func.isRequired,
|
||||
};
|
||||
|
||||
export default InstanceListItem;
|
||||
@@ -0,0 +1,298 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { InstancesAPI } from 'api';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
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,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
hostname: 'awx',
|
||||
created: '2020-07-14T19:03:49.000054Z',
|
||||
modified: '2020-08-12T20:08:02.836748Z',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
last_health_check: '2021-09-15T18:02:07.270664Z',
|
||||
cpu: 6,
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hybrid',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'instance',
|
||||
url: '/api/v2/instances/1/',
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
hostname: 'awx-control',
|
||||
created: '2020-07-14T19:03:49.000054Z',
|
||||
modified: '2020-08-12T20:08:02.836748Z',
|
||||
capacity_adjustment: '0.40',
|
||||
version: '13.0.0',
|
||||
last_health_check: '2021-09-15T18:02:07.270664Z',
|
||||
capacity: 10,
|
||||
consumed_capacity: 0,
|
||||
percent_capacity_remaining: 60.0,
|
||||
jobs_running: 0,
|
||||
jobs_total: 68,
|
||||
cpu: 6,
|
||||
memory: 2087469056,
|
||||
cpu_capacity: 24,
|
||||
mem_capacity: 1,
|
||||
enabled: true,
|
||||
managed_by_policy: true,
|
||||
node_type: 'hop',
|
||||
},
|
||||
];
|
||||
|
||||
describe('<InstanceListItem/>', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
useDebounce.mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
test('should mount successfully', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should calculate number of forks when slide changes', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').length).toBe(1);
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'10 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(1);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'24 forks'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'1 fork'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0.5);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'12 forks'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render the proper data instance', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('Td[dataLabel="Name"]').find('Link').prop('to')).toBe(
|
||||
'/instances/1/details'
|
||||
);
|
||||
expect(wrapper.find('Td').at(2).text()).toBe('awx');
|
||||
expect(wrapper.find('Progress').prop('value')).toBe(40);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(5)
|
||||
.containsMatchingElement(<div>CPU 24</div>)
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Td')
|
||||
.at(5)
|
||||
.containsMatchingElement(<div>RAM 24</div>)
|
||||
);
|
||||
expect(wrapper.find('InstanceListItem__SliderForks').text()).toContain(
|
||||
'10 forks'
|
||||
);
|
||||
});
|
||||
|
||||
test('should render checkbox', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('Td').at(1).prop('select').onSelect).toEqual(onSelect);
|
||||
});
|
||||
|
||||
test('should display instance toggle', () => {
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should display error', async () => {
|
||||
jest.useFakeTimers();
|
||||
InstancesAPI.update.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'patch',
|
||||
url: '/api/v2/instances/1',
|
||||
data: { capacity_adjustment: 0.30001 },
|
||||
},
|
||||
data: {
|
||||
capacity_adjustment: [
|
||||
'Ensure that there are no more than 3 digits in total.',
|
||||
],
|
||||
},
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
},
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ context: { network: { handleHttpError: () => {} } } }
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('Slider').prop('onChange')(0.30001);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
jest.advanceTimersByTime(210);
|
||||
await act(async () => {
|
||||
wrapper.update();
|
||||
});
|
||||
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||
});
|
||||
|
||||
test('Should render expanded row with the correct data points', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[0]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
isExpanded
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true);
|
||||
|
||||
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
|
||||
'Auto'
|
||||
);
|
||||
expect(
|
||||
wrapper.find('Detail[label="Last Health Check"]').prop('value')
|
||||
).toBe('9/15/2021, 6:02:07 PM');
|
||||
});
|
||||
test('Hop should not render some things', async () => {
|
||||
const onSelect = jest.fn();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<InstanceListItem
|
||||
instance={instance[1]}
|
||||
onSelect={onSelect}
|
||||
fetchInstances={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('InstanceToggle').length).toBe(0);
|
||||
expect(
|
||||
wrapper.find("Td[dataLabel='Instance group used capacity']").length
|
||||
).toBe(0);
|
||||
expect(wrapper.find("Td[dataLabel='Capacity Adjustment']").length).toBe(0);
|
||||
});
|
||||
});
|
||||
2
awx/ui/src/screens/Instances/InstanceList/index.js
Normal file
2
awx/ui/src/screens/Instances/InstanceList/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as InstanceList } from './InstanceList';
|
||||
export { default as InstanceListItem } from './InstanceListItem';
|
||||
43
awx/ui/src/screens/Instances/Instances.js
Normal file
43
awx/ui/src/screens/Instances/Instances.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ScreenHeader from 'components/ScreenHeader';
|
||||
import { InstanceList } from './InstanceList';
|
||||
import Instance from './Instance';
|
||||
|
||||
function Instances() {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
'/instances': t`Instances`,
|
||||
});
|
||||
|
||||
const buildBreadcrumbConfig = useCallback((instance) => {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
setBreadcrumbConfig({
|
||||
'/instances': t`Instances`,
|
||||
[`/instances/${instance.id}`]: t`${instance.hostname}`,
|
||||
[`/instances/${instance.id}/details`]: t`Details`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenHeader
|
||||
streamType="instances"
|
||||
breadcrumbConfig={breadcrumbConfig}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path="/instances/:id">
|
||||
<Instance setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/instances">
|
||||
<InstanceList />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Instances;
|
||||
1
awx/ui/src/screens/Instances/index.js
Normal file
1
awx/ui/src/screens/Instances/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Instances';
|
||||
Reference in New Issue
Block a user