Associate instances to instance groups

Associate instances to instance groups.

See: https://github.com/ansible/awx/issues/7801
This commit is contained in:
nixocio 2020-08-04 09:06:11 -04:00
parent f18d9212cb
commit 632204de83
20 changed files with 951 additions and 65 deletions

View File

@ -7,6 +7,7 @@ import Credentials from './models/Credentials';
import Groups from './models/Groups';
import Hosts from './models/Hosts';
import InstanceGroups from './models/InstanceGroups';
import Instances from './models/Instances';
import Inventories from './models/Inventories';
import InventoryScripts from './models/InventoryScripts';
import InventorySources from './models/InventorySources';
@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates';
import Organizations from './models/Organizations';
import ProjectUpdates from './models/ProjectUpdates';
import Projects from './models/Projects';
import Root from './models/Root';
import Roles from './models/Roles';
import Root from './models/Root';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
const InstancesAPI = new Instances();
const InventoriesAPI = new Inventories();
const InventoryScriptsAPI = new InventoryScripts();
const InventorySourcesAPI = new InventorySources();
@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates();
const OrganizationsAPI = new Organizations();
const ProjectUpdatesAPI = new ProjectUpdates();
const ProjectsAPI = new Projects();
const RootAPI = new Root();
const RolesAPI = new Roles();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
@ -78,6 +80,7 @@ export {
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
InstancesAPI,
InventoriesAPI,
InventoryScriptsAPI,
InventorySourcesAPI,
@ -90,8 +93,8 @@ export {
OrganizationsAPI,
ProjectUpdatesAPI,
ProjectsAPI,
RootAPI,
RolesAPI,
RootAPI,
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,

View File

@ -4,6 +4,37 @@ class InstanceGroups extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/instance_groups/';
this.associateInstance = this.associateInstance.bind(this);
this.disassociateInstance = this.disassociateInstance.bind(this);
this.readInstanceOptions = this.readInstanceOptions.bind(this);
this.readInstances = this.readInstances.bind(this);
this.readJobs = this.readJobs.bind(this);
}
associateInstance(instanceGroupId, instanceId) {
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
id: instanceId,
});
}
disassociateInstance(instanceGroupId, instanceId) {
return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, {
id: instanceId,
disassociate: true,
});
}
readInstances(id, params) {
return this.http.get(`${this.baseUrl}${id}/instances/`, { params });
}
readInstanceOptions(id) {
return this.http.options(`${this.baseUrl}${id}/instances/`);
}
readJobs(id) {
return this.http.get(`${this.baseUrl}${id}/jobs/`);
}
}

View File

@ -0,0 +1,10 @@
import Base from '../Base';
class Instances extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/instances/';
}
}
export default Instances;

View File

@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs';
import useSelected from '../../util/useSelected';
const QS_CONFIG = getQSConfig('associate', {
page: 1,
page_size: 5,
order_by: 'name',
});
const QS_CONFIG = (order_by = 'name') => {
return getQSConfig('associate', {
page: 1,
page_size: 5,
order_by,
});
};
function AssociateModal({
i18n,
@ -23,6 +25,7 @@ function AssociateModal({
fetchRequest,
optionsRequest,
isModalOpen = false,
displayKey = 'name',
}) {
const history = useHistory();
const { selected, handleSelect } = useSelected([]);
@ -34,7 +37,10 @@ function AssociateModal({
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const params = parseQueryString(
QS_CONFIG(displayKey),
history.location.search
);
const [
{
data: { count, results },
@ -52,7 +58,7 @@ function AssociateModal({
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
};
}, [fetchRequest, optionsRequest, history.location.search]),
}, [fetchRequest, optionsRequest, history.location.search, displayKey]),
{
items: [],
itemCount: 0,
@ -112,6 +118,7 @@ function AssociateModal({
]}
>
<OptionsList
displayKey={displayKey}
contentError={contentError}
deselectItem={handleSelect}
header={header}
@ -119,14 +126,14 @@ function AssociateModal({
multiple
optionCount={itemCount}
options={items}
qsConfig={QS_CONFIG}
qsConfig={QS_CONFIG(displayKey)}
readOnly={false}
selectItem={handleSelect}
value={selected}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
key: `${displayKey}__icontains`,
isDefault: true,
},
{
@ -141,7 +148,7 @@ function AssociateModal({
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: `${displayKey}`,
},
]}
searchableKeys={searchableKeys}

View File

@ -16,6 +16,7 @@ function DisassociateButton({
modalNote = '',
modalTitle = i18n._(t`Disassociate?`),
onDisassociate,
verifyCannotDisassociate = true,
}) {
const [isOpen, setIsOpen] = useState(false);
@ -25,33 +26,41 @@ function DisassociateButton({
}
function cannotDisassociate(item) {
return !item.summary_fields.user_capabilities.delete;
return !item.summary_fields?.user_capabilities?.delete;
}
function renderTooltip() {
const itemsUnableToDisassociate = itemsToDisassociate
.filter(cannotDisassociate)
.map(item => item.name)
.join(', ');
if (verifyCannotDisassociate) {
const itemsUnableToDisassociate = itemsToDisassociate
.filter(cannotDisassociate)
.map(item => item.name)
.join(', ');
if (itemsToDisassociate.some(cannotDisassociate)) {
return (
<div>
{i18n._(
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
)}
</div>
);
if (itemsToDisassociate.some(cannotDisassociate)) {
return (
<div>
{i18n._(
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
)}
</div>
);
}
}
if (itemsToDisassociate.length) {
return i18n._(t`Disassociate`);
}
return i18n._(t`Select a row to disassociate`);
}
const isDisabled =
itemsToDisassociate.length === 0 ||
itemsToDisassociate.some(cannotDisassociate);
let isDisabled = false;
if (verifyCannotDisassociate) {
isDisabled =
itemsToDisassociate.length === 0 ||
itemsToDisassociate.some(cannotDisassociate);
} else {
isDisabled = itemsToDisassociate.length === 0;
}
// NOTE: Once PF supports tooltips on disabled elements,
// we can delete the extra <div> around the <DeleteButton> below.
@ -102,7 +111,7 @@ function DisassociateButton({
{itemsToDisassociate.map(item => (
<span key={item.id}>
<strong>{item.name}</strong>
<strong>{item.hostname ? item.hostname : item.name}</strong>
<br />
</span>
))}

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Tooltip } from '@patternfly/react-core';
import AlertModal from '../AlertModal';
import ErrorDetail from '../ErrorDetail';
import useRequest from '../../util/useRequest';
import { InstancesAPI } from '../../api';
import { useConfig } from '../../contexts/Config';
function InstanceToggle({
className,
fetchInstances,
instance,
onToggle,
i18n,
}) {
const { me } = useConfig();
const [isEnabled, setIsEnabled] = useState(instance.enabled);
const [showError, setShowError] = useState(false);
const { result, isLoading, error, request: toggleInstance } = useRequest(
useCallback(async () => {
await InstancesAPI.update(instance.id, { enabled: !isEnabled });
await fetchInstances();
return !isEnabled;
}, [instance, isEnabled, fetchInstances]),
instance.enabled
);
useEffect(() => {
if (result !== isEnabled) {
setIsEnabled(result);
if (onToggle) {
onToggle(result);
}
}
}, [result, isEnabled, onToggle]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
return (
<>
<Tooltip
content={i18n._(
t`Set the instance online or offline. If offline, jobs will not be assigned to this instance.`
)}
position="top"
>
<Switch
className={className}
css="display: inline-flex;"
id={`host-${instance.id}-toggle`}
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={isEnabled}
isDisabled={isLoading || !me.is_superuser}
onChange={toggleInstance}
aria-label={i18n._(t`Toggle instance`)}
/>
</Tooltip>
{showError && error && !isLoading && (
<AlertModal
variant="error"
title={i18n._(t`Error!`)}
isOpen={error && !isLoading}
onClose={() => setShowError(false)}
>
{i18n._(t`Failed to toggle instance.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
export default withI18n()(InstanceToggle);

View File

@ -0,0 +1,114 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InstancesAPI } from '../../api';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceToggle from './InstanceToggle';
jest.mock('../../api');
const mockInstance = {
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'awx',
created: '2020-07-14T19:03:49.000054Z',
modified: '2020-08-05T19:17:18.080033Z',
capacity_adjustment: '0.40',
version: '13.0.0',
capacity: 10,
consumed_capacity: 0,
percent_capacity_remaining: 100.0,
jobs_running: 0,
jobs_total: 67,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
};
describe('<InstanceToggle>', () => {
const onToggle = jest.fn();
const fetchInstances = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
test('should show toggle off', async () => {
const wrapper = mountWithContexts(
<InstanceToggle
instance={mockInstance}
fetchInstances={fetchInstances}
onToggle={onToggle}
/>
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
enabled: false,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
expect(onToggle).toHaveBeenCalledWith(false);
expect(fetchInstances).toHaveBeenCalledTimes(1);
});
test('should show toggle on', async () => {
const wrapper = mountWithContexts(
<InstanceToggle
instance={{
...mockInstance,
enabled: false,
}}
onToggle={onToggle}
fetchInstances={fetchInstances}
/>
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(false);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
expect(InstancesAPI.update).toHaveBeenCalledWith(1, {
enabled: true,
});
wrapper.update();
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
expect(onToggle).toHaveBeenCalledWith(true);
expect(fetchInstances).toHaveBeenCalledTimes(1);
});
test('should show error modal', async () => {
InstancesAPI.update.mockImplementation(() => {
throw new Error('nope');
});
const wrapper = mountWithContexts(
<InstanceToggle instance={mockInstance} />
);
expect(wrapper.find('Switch').prop('isChecked')).toEqual(true);
await act(async () => {
wrapper.find('Switch').invoke('onChange')();
});
wrapper.update();
const modal = wrapper.find('AlertModal');
expect(modal).toHaveLength(1);
expect(modal.prop('isOpen')).toEqual(true);
act(() => {
modal.invoke('onClose')();
});
wrapper.update();
expect(wrapper.find('AlertModal')).toHaveLength(0);
});
});

View File

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

View File

@ -42,6 +42,7 @@ function OptionsList({
renderItemChip,
isLoading,
i18n,
displayKey,
}) {
return (
<ModalList>
@ -52,6 +53,7 @@ function OptionsList({
onRemove={item => deselectItem(item)}
isReadOnly={readOnly}
renderItemChip={renderItemChip}
displayKey={displayKey}
/>
)}
<PaginatedDataList
@ -70,8 +72,8 @@ function OptionsList({
<CheckboxListItem
key={item.id}
itemId={item.id}
name={multiple ? item.name : name}
label={item.name}
name={multiple ? item[displayKey] : name}
label={item[displayKey]}
isSelected={value.some(i => i.id === item.id)}
onSelect={() => selectItem(item)}
onDeselect={() => deselectItem(item)}
@ -91,22 +93,24 @@ const Item = shape({
url: string,
});
OptionsList.propTypes = {
value: arrayOf(Item).isRequired,
options: arrayOf(Item).isRequired,
optionCount: number.isRequired,
searchColumns: SearchColumns,
sortColumns: SortColumns,
multiple: bool,
qsConfig: QSConfig.isRequired,
selectItem: func.isRequired,
deselectItem: func.isRequired,
displayKey: string,
multiple: bool,
optionCount: number.isRequired,
options: arrayOf(Item).isRequired,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
searchColumns: SearchColumns,
selectItem: func.isRequired,
sortColumns: SortColumns,
value: arrayOf(Item).isRequired,
};
OptionsList.defaultProps = {
multiple: false,
renderItemChip: null,
searchColumns: [],
sortColumns: [],
displayKey: 'name',
};
export default withI18n()(OptionsList);

View File

@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
function ToolbarAddButton({
linkTo,
onClick,
i18n,
isDisabled,
defaultLabel = i18n._(t`Add`),
}) {
const { isKebabified } = useKebabifiedMenu();
if (!linkTo && !onClick) {
@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
);
}
if (isKebabified) {
return (
<DropdownItem
@ -23,28 +30,28 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
to={linkTo}
onClick={!onClick ? undefined : onClick}
>
{i18n._(t`Add`)}
{defaultLabel}
</DropdownItem>
);
}
if (linkTo) {
return (
<Tooltip content={i18n._(t`Add`)} position="top">
<Tooltip content={defaultLabel} position="top">
<Button
isDisabled={isDisabled}
component={Link}
to={linkTo}
variant="primary"
aria-label={i18n._(t`Add`)}
aria-label={defaultLabel}
>
{i18n._(t`Add`)}
{defaultLabel}
</Button>
</Tooltip>
);
}
return (
<Button variant="primary" aria-label={i18n._(t`Add`)} onClick={onClick}>
{i18n._(t`Add`)}
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
{defaultLabel}
</Button>
);
}

View File

@ -21,7 +21,7 @@ import JobList from '../../components/JobList';
import InstanceGroupDetails from './InstanceGroupDetails';
import InstanceGroupEdit from './InstanceGroupEdit';
import Instances from './Instances';
import InstanceList from './Instances/InstanceList';
function InstanceGroup({ i18n, setBreadcrumb }) {
const { id } = useParams();
@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) {
<InstanceGroupDetails instanceGroup={instanceGroup} />
</Route>
<Route path="/instance_groups/:id/instances">
<Instances />
<InstanceList />
</Route>
<Route path="/instance_groups/:id/jobs">
<JobList

View File

@ -45,7 +45,7 @@ const instanceGroups = {
const options = { data: { actions: { POST: true } } };
describe('<InstanceGroupList', () => {
describe('<InstanceGroupList />', () => {
let wrapper;
test('should have data fetched and render 3 rows', async () => {

View File

@ -0,0 +1,245 @@
import React, { useCallback, useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useLocation, useParams } from 'react-router-dom';
import 'styled-components/macro';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList, {
ToolbarAddButton,
} from '../../../components/PaginatedDataList';
import DisassociateButton from '../../../components/DisassociateButton';
import AssociateModal from '../../../components/AssociateModal';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import useRequest, {
useDeleteItems,
useDismissableError,
} from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { InstanceGroupsAPI, InstancesAPI } from '../../../api';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import InstanceListItem from './InstanceListItem';
const QS_CONFIG = getQSConfig('instance', {
page: 1,
page_size: 20,
order_by: 'hostname',
});
function InstanceList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const location = useLocation();
const { id: instanceGroupId } = useParams();
const {
result: {
instances,
count,
actions,
relatedSearchableKeys,
searchableKeys,
},
error: contentError,
isLoading,
request: fetchInstances,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, responseActions] = await Promise.all([
InstanceGroupsAPI.readInstances(instanceGroupId, params),
InstanceGroupsAPI.readInstanceOptions(instanceGroupId),
]);
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: Object.keys(
responseActions.data.actions?.GET || {}
).filter(key => responseActions.data.actions?.GET[key].filterable),
};
}, [location.search, instanceGroupId]),
{
instances: [],
count: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
}
);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
instances
);
useEffect(() => {
fetchInstances();
}, [fetchInstances]);
const {
isLoading: isDisassociateLoading,
deleteItems: disassociateInstances,
deletionError: disassociateError,
} = useDeleteItems(
useCallback(async () => {
return Promise.all(
selected.map(instance =>
InstanceGroupsAPI.disassociateInstance(instanceGroupId, instance.id)
)
);
}, [instanceGroupId, selected]),
{
qsConfig: QS_CONFIG,
allItemsSelected: isAllSelected,
fetchItems: fetchInstances,
}
);
const { request: handleAssociate, error: associateError } = useRequest(
useCallback(
async instancesToAssociate => {
await Promise.all(
instancesToAssociate.map(instance =>
InstanceGroupsAPI.associateInstance(instanceGroupId, instance.id)
)
);
fetchInstances();
},
[instanceGroupId, fetchInstances]
)
);
const handleDisassociate = async () => {
await disassociateInstances();
setSelected([]);
};
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const fetchInstancesToAssociate = useCallback(
params => {
return InstancesAPI.read(
mergeParams(params, { not__rampart_groups__id: instanceGroupId })
);
},
[instanceGroupId]
);
const readInstancesOptions = () =>
InstanceGroupsAPI.readInstanceOptions(instanceGroupId);
return (
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading || isDisassociateLoading}
items={instances}
itemCount={count}
pluralizedItemName={i18n._(t`Instances`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'hostname',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'hostname',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...instances] : [])
}
qsConfig={QS_CONFIG}
additionalControls={[
...(canAdd
? [
<ToolbarAddButton
key="associate"
onClick={() => setIsModalOpen(true)}
defaultLabel={i18n._(t`Associate`)}
/>,
]
: []),
<DisassociateButton
verifyCannotDisassociate={false}
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={i18n._(
t`Disassociate instance from instance group?`
)}
/>,
]}
emptyStateControls={
canAdd ? (
<ToolbarAddButton
key="add"
onClick={() => setIsModalOpen(true)}
/>
) : null
}
/>
)}
renderItem={instance => (
<InstanceListItem
key={instance.id}
value={instance.hostname}
instance={instance}
onSelect={() => handleSelect(instance)}
isSelected={selected.some(row => row.id === instance.id)}
fetchInstances={fetchInstances}
/>
)}
/>
{isModalOpen && (
<AssociateModal
header={i18n._(t`Instances`)}
fetchRequest={fetchInstancesToAssociate}
isModalOpen={isModalOpen}
onAssociate={handleAssociate}
onClose={() => setIsModalOpen(false)}
title={i18n._(t`Select Instances`)}
optionsRequest={readInstancesOptions}
displayKey="hostname"
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={i18n._(t`Error!`)}
variant="error"
>
{associateError
? i18n._(t`Failed to associate.`)
: i18n._(t`Failed to disassociate one or more instances.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
export default withI18n()(InstanceList);

View File

@ -0,0 +1,156 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { InstanceGroupsAPI } from '../../../api';
import InstanceList from './InstanceList';
jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
instanceGroupId: 2,
}),
}));
const instances = [
{
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
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,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
},
{
id: 2,
type: 'instance',
url: '/api/v2/instances/2/',
related: {
jobs: '/api/v2/instances/2/jobs/',
instance_groups: '/api/v2/instances/2/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'foo',
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,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: false,
},
{
id: 3,
type: 'instance',
url: '/api/v2/instances/3/',
related: {
jobs: '/api/v2/instances/3/jobs/',
instance_groups: '/api/v2/instances/3/instance_groups/',
},
uuid: '00000000-0000-0000-0000-000000000000',
hostname: 'bar',
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,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: false,
managed_by_policy: true,
},
];
const options = { data: { actions: { POST: true } } };
describe('<InstanceList/>', () => {
let wrapper;
beforeEach(async () => {
InstanceGroupsAPI.readInstances.mockResolvedValue({
data: {
count: instances.length,
results: instances,
},
});
InstanceGroupsAPI.readInstanceOptions.mockResolvedValue(options);
const history = createMemoryHistory({
initialEntries: ['/instance_groups/1/instances'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/instance_groups/:id/instances">
<InstanceList />
</Route>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
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(InstanceGroupsAPI.readInstances).toHaveBeenCalled();
expect(InstanceGroupsAPI.readInstanceOptions).toHaveBeenCalled();
expect(wrapper.find('InstanceListItem').length).toBe(3);
});
test('should show associate group modal when adding an existing group', () => {
wrapper.find('ToolbarAddButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
expect(wrapper.find('AssociateModal').length).toBe(0);
});
});

View File

@ -0,0 +1,136 @@
import React from 'react';
import { bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import {
Badge as PFBadge,
Progress,
ProgressMeasureLocation,
ProgressSize,
DataListAction,
DataListCheck,
DataListItem,
DataListItemRow,
DataListItemCells,
} from '@patternfly/react-core';
import _DataListCell from '../../../components/DataListCell';
import InstanceToggle from '../../../components/InstanceToggle';
import { Instance } from '../../../types';
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
`;
const DataListCell = styled(_DataListCell)`
white-space: nowrap;
`;
const Badge = styled(PFBadge)`
margin-left: 8px;
`;
const ListGroup = styled.span`
margin-left: 12px;
&:first-of-type {
margin-left: 0;
}
`;
function InstanceListItem({
instance,
isSelected,
onSelect,
fetchInstances,
i18n,
}) {
const labelId = `check-action-${instance.id}`;
function usedCapacity(item) {
if (item.enabled) {
return (
<Progress
value={100 - item.percent_capacity_remaining}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
title={i18n._(t`Used capacity`)}
/>
);
}
return <Unavailable>{i18n._(t`Unavailable`)}</Unavailable>;
}
return (
<DataListItem
aria-labelledby={labelId}
id={`${instance.id}`}
key={instance.id}
>
<DataListItemRow>
<DataListCheck
aria-labelledby={labelId}
checked={isSelected}
id={`instances-${instance.id}`}
onChange={onSelect}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name" aria-label={i18n._(t`instance host name`)}>
<b>{instance.hostname}</b>
</DataListCell>,
<DataListCell key="type" aria-label={i18n._(t`instance type`)}>
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
<span id={labelId}>
{instance.managed_by_policy
? i18n._(t`Auto`)
: i18n._(t`Manual`)}
</span>
</DataListCell>,
<DataListCell
key="related-field-counts"
aria-label={i18n._(t`instance counts`)}
width={2}
>
<ListGroup>
<b>{i18n._(t`Running jobs`)}</b>
<Badge isRead>{instance.jobs_running}</Badge>
</ListGroup>
<ListGroup>
<b>{i18n._(t`Total jobs`)}</b>
<Badge isRead>{instance.jobs_total}</Badge>
</ListGroup>
</DataListCell>,
<DataListCell
key="capacity"
aria-label={i18n._(t`instance group used capacity`)}
>
{usedCapacity(instance)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstances}
instance={instance}
/>
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
InstanceListItem.prototype = {
instance: Instance.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default withI18n()(InstanceListItem);

View File

@ -0,0 +1,89 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import InstanceListItem from './InstanceListItem';
const instance = [
{
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
related: {
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
},
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,
cpu: 6,
memory: 2087469056,
cpu_capacity: 24,
mem_capacity: 1,
enabled: true,
managed_by_policy: true,
},
];
describe('<InstanceListItem/>', () => {
let wrapper;
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(wrapper.find('InstanceListItem').length).toBe(1);
});
test('should render the proper data instance', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected={false}
onSelect={() => {}}
/>
);
});
expect(
wrapper.find('PFDataListCell[aria-label="instance host name"]').text()
).toBe('awx');
expect(wrapper.find('Progress').prop('value')).toBe(40);
expect(
wrapper.find('PFDataListCell[aria-label="instance type"]').text()
).toBe('TypeAuto');
expect(wrapper.find('input#instances-1').prop('checked')).toBe(false);
});
test('should be checked', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InstanceListItem
instance={instance[0]}
isSelected
onSelect={() => {}}
/>
);
});
expect(wrapper.find('input#instances-1').prop('checked')).toBe(true);
});
test('should display instance toggle', () => {
expect(wrapper.find('InstanceToggle').length).toBe(1);
});
});

View File

@ -1,14 +0,0 @@
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
function Instances() {
return (
<PageSection>
<Card>
<div>Instances</div>
</Card>
</PageSection>
);
}
export default Instances;

View File

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

View File

@ -118,6 +118,11 @@ export const InstanceGroup = shape({
name: string.isRequired,
});
export const Instance = shape({
id: number.isRequired,
name: string.isRequired,
});
export const Label = shape({
id: number.isRequired,
name: string.isRequired,

View File

@ -42,6 +42,7 @@ const defaultContexts = {
ansible_version: null,
custom_virtualenvs: [],
version: null,
me: { is_superuser: true },
toJSON: () => '/config/',
},
router: {