Adds top level instances list

This commit is contained in:
Alex Corey
2022-01-31 14:00:24 -05:00
parent 326d12382f
commit 50e8c299c6
15 changed files with 1562 additions and 0 deletions

View File

@@ -56,6 +56,7 @@ function ToolbarAddButton({
return (
<Button
ouiaId={ouiaId}
isDisabled={isDisabled}
icon={showToggleIndicator ? <CaretDownIcon /> : null}
iconPosition={showToggleIndicator ? 'right' : null}
variant="primary"

View File

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

View File

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

View File

@@ -150,6 +150,7 @@ function InstanceList() {
mergeParams(params, {
...{ not__rampart_groups__id: instanceGroupId },
...{ not__node_type: 'control' },
...{ not__node_type: 'hop' },
})
),
[instanceGroupId]

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

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

View File

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

View File

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

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

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

View 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`}
&nbsp;
{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;

View File

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

View File

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

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

View File

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