Merge pull request #11653 from AlexSCorey/11588-TopLevelInstances

Adds top level instances list
This commit is contained in:
Alex Corey 2022-02-14 16:06:55 -05:00 committed by GitHub
commit dabae456d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 11012 additions and 8337 deletions

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Plural, t } from '@lingui/macro';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { useKebabifiedMenu } from 'contexts/Kebabified';
function HealthCheckButton({ isDisabled, onClick, selectedItems }) {
const { isKebabified } = useKebabifiedMenu();
const selectedItemsCount = selectedItems.length;
const buildTooltip = () =>
selectedItemsCount ? (
<Plural
value={selectedItemsCount}
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.`
);
if (isKebabified) {
return (
<Tooltip data-cy="healthCheckTooltip" content={buildTooltip()}>
<DropdownItem
key="approve"
isDisabled={isDisabled || !selectedItemsCount}
component="button"
onClick={onClick}
ouiaId="health-check"
>
{t`Health Check`}
</DropdownItem>
</Tooltip>
);
}
return (
<Tooltip data-cy="healthCheckTooltip" content={buildTooltip()}>
<div>
<Button
isDisabled={isDisabled || !selectedItemsCount}
variant="secondary"
ouiaId="health-check"
onClick={onClick}
>{t`Health Check`}</Button>
</div>
</Tooltip>
);
}
export default HealthCheckButton;

View File

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

View File

@ -69,6 +69,7 @@ export function HeaderCell({
idPrefix,
className,
children,
tooltip,
}) {
const sort = sortKey
? {
@ -79,6 +80,11 @@ export function HeaderCell({
: null;
return (
<Th
info={
tooltip && {
popover: <div>{tooltip}</div>,
}
}
id={sortKey ? `${idPrefix}-${sortKey}` : null}
className={className}
sort={sort}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Plural, t } from '@lingui/macro';
import { t } from '@lingui/macro';
import { useLocation, useParams } from 'react-router-dom';
import 'styled-components/macro';
@ -16,7 +15,6 @@ import DisassociateButton from 'components/DisassociateButton';
import AssociateModal from 'components/AssociateModal';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import useRequest, {
useDeleteItems,
useDismissableError,
@ -24,8 +22,7 @@ import useRequest, {
import useSelected from 'hooks/useSelected';
import { InstanceGroupsAPI, InstancesAPI } from 'api';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import { Button, Tooltip } from '@patternfly/react-core';
import HealthCheckButton from 'components/HealthCheckButton/HealthCheckButton';
import InstanceListItem from './InstanceListItem';
const QS_CONFIG = getQSConfig('instance', {
@ -83,13 +80,22 @@ function InstanceList() {
fetchInstances();
}, [fetchInstances]);
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
const {
error: healthCheckError,
request: fetchHealthCheck,
isLoading: isHealthCheckLoading,
} = useRequest(
useCallback(async () => {
await Promise.all(selected.map(({ id }) => InstancesAPI.healthCheck(id)));
fetchInstances();
}, [selected, fetchInstances])
);
const handleHealthCheck = async () => {
await fetchHealthCheck();
clearSelected();
};
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateInstances,
@ -150,6 +156,7 @@ function InstanceList() {
mergeParams(params, {
...{ not__rampart_groups__id: instanceGroupId },
...{ not__node_type: 'control' },
...{ not__node_type: 'hop' },
})
),
[instanceGroupId]
@ -167,7 +174,9 @@ function InstanceList() {
<>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
hasContentLoading={
isLoading || isDisassociateLoading || isHealthCheckLoading
}
items={instances}
itemCount={count}
pluralizedItemName={t`Instances`}
@ -181,6 +190,15 @@ function InstanceList() {
key: 'hostname__icontains',
isDefault: true,
},
{
name: t`Node Type`,
key: `or__node_type`,
options: [
[`control`, t`Control`],
[`execution`, t`Execution`],
[`hybrid`, t`Hybrid`],
],
},
]}
toolbarSortColumns={[
{
@ -215,28 +233,11 @@ function InstanceList() {
itemsToDisassociate={selected}
modalTitle={t`Disassociate instance from instance group?`}
/>,
<Tooltip
content={
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.`
)
}
>
<div>
<Button
isDisabled={!canAdd || !selected.length}
variant="secondary"
ouiaId="health-check"
onClick={fetchHealthCheck}
>{t`Health Check`}</Button>
</div>
</Tooltip>,
<HealthCheckButton
isDisabled={!canAdd}
onClick={handleHealthCheck}
selectedItems={selected}
/>,
]}
emptyStateControls={
canAdd ? (
@ -252,8 +253,7 @@ function InstanceList() {
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell>{t`Running Jobs`}</HeaderCell>
<HeaderCell>{t`Total Jobs`}</HeaderCell>
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>
<HeaderCell>{t`Used Capacity`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>

View File

@ -19,6 +19,7 @@ import StatusLabel from 'components/StatusLabel';
import { Instance } from 'types';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import useDebounce from 'hooks/useDebounce';
import computeForks from 'util/computeForks';
import { InstancesAPI } from 'api';
import { useConfig } from 'contexts/Config';
import AlertModal from 'components/AlertModal';
@ -42,15 +43,6 @@ const SliderForks = styled.div`
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,
@ -61,6 +53,7 @@ function InstanceListItem({
rowIndex,
}) {
const { me = {} } = useConfig();
const { id } = useParams();
const [forks, setForks] = useState(
computeForks(
instance.mem_capacity,
@ -68,7 +61,6 @@ function InstanceListItem({
instance.capacity_adjustment
)
);
const { id } = useParams();
const labelId = `check-action-${instance.id}`;
@ -147,8 +139,7 @@ function InstanceListItem({
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
</Tooltip>
</Td>
<Td dataLabel={t`Running Jobs`}>{instance.jobs_running}</Td>
<Td dataLabel={t`Total Jobs`}>{instance.jobs_total}</Td>
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
<Td dataLabel={t`Capacity Adjustment`}>
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instance.cpu_capacity}`}</div>
@ -197,12 +188,23 @@ function InstanceListItem({
<Td colSpan={7}>
<ExpandableRowContent>
<DetailList>
<Detail label={t`Node Type`} value={instance.node_type} />
<Detail
data-cy="running-jobs"
value={instance.jobs_running}
label={t`Running Jobs`}
/>
<Detail
data-cy="total-jobs"
value={instance.jobs_total}
label={t`Total Jobs`}
/>
<Detail
data-cy="policy-type"
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail
data-cy="last-health-check"
label={t`Last Health Check`}
value={formatDateString(instance.last_health_check)}
/>

View File

@ -274,9 +274,8 @@ describe('<InstanceListItem/>', () => {
);
});
expect(wrapper.find('InstanceListItem').prop('isExpanded')).toBe(true);
expect(wrapper.find('Detail[label="Node Type"]').prop('value')).toBe(
'hybrid'
);
expect(wrapper.find('Detail[label="Running Jobs"]').prop('value')).toBe(0);
expect(wrapper.find('Detail[label="Total Jobs"]').prop('value')).toBe(68);
expect(wrapper.find('Detail[label="Policy Type"]').prop('value')).toBe(
'Auto'
);

View File

@ -0,0 +1,51 @@
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 default Instance;

View File

@ -0,0 +1,256 @@
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} />
{!isHopNode && (
<>
<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`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,189 @@
import React, { useCallback, useEffect } from 'react';
import { t } from '@lingui/macro';
import { useLocation } from 'react-router-dom';
import 'styled-components/macro';
import { PageSection, Card } from '@patternfly/react-core';
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 HealthCheckButton from 'components/HealthCheckButton';
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.filter((i) => i.node_type !== 'hop'));
useEffect(() => {
fetchInstances();
}, [fetchInstances]);
const {
error: healthCheckError,
request: fetchHealthCheck,
isLoading: isHealthCheckLoading,
} = useRequest(
useCallback(async () => {
await Promise.all(
selected
.filter(({ node_type }) => node_type !== 'hop')
.map(({ id }) => InstancesAPI.healthCheck(id))
);
fetchInstances();
}, [selected, fetchInstances])
);
const handleHealthCheck = async () => {
await fetchHealthCheck();
clearSelected();
};
const { error, dismissError } = useDismissableError(healthCheckError);
const { expanded, isAllExpanded, handleExpand, expandAll } =
useExpanded(instances);
return (
<>
<PageSection>
<Card>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading || isHealthCheckLoading}
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={[
<HealthCheckButton
onClick={handleHealthCheck}
selectedItems={selected}
/>,
]}
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell
tooltip={t`Cannot run health check on hop nodes.`}
sortKey="hostname"
>{t`Name`}</HeaderCell>
<HeaderCell sortKey="errors">{t`Status`}</HeaderCell>
<HeaderCell sortKey="node_type">{t`Node Type`}</HeaderCell>
<HeaderCell>{t`Capacity Adjustment`}</HeaderCell>
<HeaderCell>{t`Used Capacity`}</HeaderCell>
<HeaderCell>{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,193 @@
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();
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);
});
});

View File

@ -0,0 +1,252 @@
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 computeForks from 'util/computeForks';
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 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 });
};
const isHopNode = instance.node_type === 'hop';
return (
<>
<Tr
id={`instance-row-${instance.id}`}
ouiaId={`instance-row-${instance.id}`}
>
{isHopNode ? (
<Td />
) : (
<Td
expand={{
rowIndex,
isExpanded,
onToggle: onExpand,
}}
/>
)}
<Td
select={{
rowIndex,
isSelected,
onSelect,
disable: isHopNode,
}}
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 ?? instance.last_seen
)}
</div>
}
>
<StatusLabel status={instance.errors ? 'error' : 'healthy'} />
</Tooltip>
</Td>
<Td dataLabel={t`Node Type`}>{instance.node_type}</Td>
{!isHopNode && (
<>
<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>
<Td
dataLabel={t`Instance group used capacity`}
css="--pf-c-table--cell--MinWidth: 175px;"
>
{usedCapacity(instance)}
</Td>
<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>
{!isHopNode && (
<Tr
ouiaId={`instance-row-${instance.id}-expanded`}
isExpanded={isExpanded}
>
<Td colSpan={2} />
<Td colSpan={7}>
<ExpandableRowContent>
<DetailList>
<Detail
data-cy="running-jobs"
value={instance.jobs_running}
label={t`Running Jobs`}
/>
<Detail
data-cy="total-jobs"
value={instance.jobs_total}
label={t`Total Jobs`}
/>
<Detail
data-cy="policy-type"
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail
data-cy="last-health-check"
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,40 @@
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}`]: `${instance.hostname}`,
[`/instances/${instance.id}/details`]: t`Details`,
});
}, []);
return (
<>
<ScreenHeader streamType="instance" 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';

View File

@ -0,0 +1,12 @@
export default function computeForks(
memCapacity,
cpuCapacity,
selectedCapacityAdjustment
) {
const minCapacity = Math.min(memCapacity, cpuCapacity);
const maxCapacity = Math.max(memCapacity, cpuCapacity);
return Math.floor(
minCapacity + (maxCapacity - minCapacity) * selectedCapacityAdjustment
);
}