mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 01:17:37 -02:30
Associate instances to instance groups
Associate instances to instance groups. See: https://github.com/ansible/awx/issues/7801
This commit is contained in:
@@ -7,6 +7,7 @@ import Credentials from './models/Credentials';
|
|||||||
import Groups from './models/Groups';
|
import Groups from './models/Groups';
|
||||||
import Hosts from './models/Hosts';
|
import Hosts from './models/Hosts';
|
||||||
import InstanceGroups from './models/InstanceGroups';
|
import InstanceGroups from './models/InstanceGroups';
|
||||||
|
import Instances from './models/Instances';
|
||||||
import Inventories from './models/Inventories';
|
import Inventories from './models/Inventories';
|
||||||
import InventoryScripts from './models/InventoryScripts';
|
import InventoryScripts from './models/InventoryScripts';
|
||||||
import InventorySources from './models/InventorySources';
|
import InventorySources from './models/InventorySources';
|
||||||
@@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates';
|
|||||||
import Organizations from './models/Organizations';
|
import Organizations from './models/Organizations';
|
||||||
import ProjectUpdates from './models/ProjectUpdates';
|
import ProjectUpdates from './models/ProjectUpdates';
|
||||||
import Projects from './models/Projects';
|
import Projects from './models/Projects';
|
||||||
import Root from './models/Root';
|
|
||||||
import Roles from './models/Roles';
|
import Roles from './models/Roles';
|
||||||
|
import Root from './models/Root';
|
||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
@@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials();
|
|||||||
const GroupsAPI = new Groups();
|
const GroupsAPI = new Groups();
|
||||||
const HostsAPI = new Hosts();
|
const HostsAPI = new Hosts();
|
||||||
const InstanceGroupsAPI = new InstanceGroups();
|
const InstanceGroupsAPI = new InstanceGroups();
|
||||||
|
const InstancesAPI = new Instances();
|
||||||
const InventoriesAPI = new Inventories();
|
const InventoriesAPI = new Inventories();
|
||||||
const InventoryScriptsAPI = new InventoryScripts();
|
const InventoryScriptsAPI = new InventoryScripts();
|
||||||
const InventorySourcesAPI = new InventorySources();
|
const InventorySourcesAPI = new InventorySources();
|
||||||
@@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates();
|
|||||||
const OrganizationsAPI = new Organizations();
|
const OrganizationsAPI = new Organizations();
|
||||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||||
const ProjectsAPI = new Projects();
|
const ProjectsAPI = new Projects();
|
||||||
const RootAPI = new Root();
|
|
||||||
const RolesAPI = new Roles();
|
const RolesAPI = new Roles();
|
||||||
|
const RootAPI = new Root();
|
||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
@@ -78,6 +80,7 @@ export {
|
|||||||
GroupsAPI,
|
GroupsAPI,
|
||||||
HostsAPI,
|
HostsAPI,
|
||||||
InstanceGroupsAPI,
|
InstanceGroupsAPI,
|
||||||
|
InstancesAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
InventoryScriptsAPI,
|
InventoryScriptsAPI,
|
||||||
InventorySourcesAPI,
|
InventorySourcesAPI,
|
||||||
@@ -90,8 +93,8 @@ export {
|
|||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
ProjectUpdatesAPI,
|
ProjectUpdatesAPI,
|
||||||
ProjectsAPI,
|
ProjectsAPI,
|
||||||
RootAPI,
|
|
||||||
RolesAPI,
|
RolesAPI,
|
||||||
|
RootAPI,
|
||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ class InstanceGroups extends Base {
|
|||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/instance_groups/';
|
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/`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Instances.js
Normal file
10
awx/ui_next/src/api/models/Instances.js
Normal 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;
|
||||||
@@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest';
|
|||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import useSelected from '../../util/useSelected';
|
import useSelected from '../../util/useSelected';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('associate', {
|
const QS_CONFIG = (order_by = 'name') => {
|
||||||
page: 1,
|
return getQSConfig('associate', {
|
||||||
page_size: 5,
|
page: 1,
|
||||||
order_by: 'name',
|
page_size: 5,
|
||||||
});
|
order_by,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function AssociateModal({
|
function AssociateModal({
|
||||||
i18n,
|
i18n,
|
||||||
@@ -23,6 +25,7 @@ function AssociateModal({
|
|||||||
fetchRequest,
|
fetchRequest,
|
||||||
optionsRequest,
|
optionsRequest,
|
||||||
isModalOpen = false,
|
isModalOpen = false,
|
||||||
|
displayKey = 'name',
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { selected, handleSelect } = useSelected([]);
|
const { selected, handleSelect } = useSelected([]);
|
||||||
@@ -34,7 +37,10 @@ function AssociateModal({
|
|||||||
isLoading,
|
isLoading,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(
|
||||||
|
QS_CONFIG(displayKey),
|
||||||
|
history.location.search
|
||||||
|
);
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
data: { count, results },
|
data: { count, results },
|
||||||
@@ -52,7 +58,7 @@ function AssociateModal({
|
|||||||
actionsResponse.data.actions?.GET || {}
|
actionsResponse.data.actions?.GET || {}
|
||||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [fetchRequest, optionsRequest, history.location.search]),
|
}, [fetchRequest, optionsRequest, history.location.search, displayKey]),
|
||||||
{
|
{
|
||||||
items: [],
|
items: [],
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
@@ -112,6 +118,7 @@ function AssociateModal({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
displayKey={displayKey}
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
deselectItem={handleSelect}
|
deselectItem={handleSelect}
|
||||||
header={header}
|
header={header}
|
||||||
@@ -119,14 +126,14 @@ function AssociateModal({
|
|||||||
multiple
|
multiple
|
||||||
optionCount={itemCount}
|
optionCount={itemCount}
|
||||||
options={items}
|
options={items}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG(displayKey)}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
selectItem={handleSelect}
|
selectItem={handleSelect}
|
||||||
value={selected}
|
value={selected}
|
||||||
searchColumns={[
|
searchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
key: 'name__icontains',
|
key: `${displayKey}__icontains`,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,7 +148,7 @@ function AssociateModal({
|
|||||||
sortColumns={[
|
sortColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
key: 'name',
|
key: `${displayKey}`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
searchableKeys={searchableKeys}
|
searchableKeys={searchableKeys}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function DisassociateButton({
|
|||||||
modalNote = '',
|
modalNote = '',
|
||||||
modalTitle = i18n._(t`Disassociate?`),
|
modalTitle = i18n._(t`Disassociate?`),
|
||||||
onDisassociate,
|
onDisassociate,
|
||||||
|
verifyCannotDisassociate = true,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -25,33 +26,41 @@ function DisassociateButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cannotDisassociate(item) {
|
function cannotDisassociate(item) {
|
||||||
return !item.summary_fields.user_capabilities.delete;
|
return !item.summary_fields?.user_capabilities?.delete;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTooltip() {
|
function renderTooltip() {
|
||||||
const itemsUnableToDisassociate = itemsToDisassociate
|
if (verifyCannotDisassociate) {
|
||||||
.filter(cannotDisassociate)
|
const itemsUnableToDisassociate = itemsToDisassociate
|
||||||
.map(item => item.name)
|
.filter(cannotDisassociate)
|
||||||
.join(', ');
|
.map(item => item.name)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
if (itemsToDisassociate.some(cannotDisassociate)) {
|
if (itemsToDisassociate.some(cannotDisassociate)) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{i18n._(
|
{i18n._(
|
||||||
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemsToDisassociate.length) {
|
if (itemsToDisassociate.length) {
|
||||||
return i18n._(t`Disassociate`);
|
return i18n._(t`Disassociate`);
|
||||||
}
|
}
|
||||||
return i18n._(t`Select a row to disassociate`);
|
return i18n._(t`Select a row to disassociate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled =
|
let isDisabled = false;
|
||||||
itemsToDisassociate.length === 0 ||
|
if (verifyCannotDisassociate) {
|
||||||
itemsToDisassociate.some(cannotDisassociate);
|
isDisabled =
|
||||||
|
itemsToDisassociate.length === 0 ||
|
||||||
|
itemsToDisassociate.some(cannotDisassociate);
|
||||||
|
} else {
|
||||||
|
isDisabled = itemsToDisassociate.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Once PF supports tooltips on disabled elements,
|
// NOTE: Once PF supports tooltips on disabled elements,
|
||||||
// we can delete the extra <div> around the <DeleteButton> below.
|
// we can delete the extra <div> around the <DeleteButton> below.
|
||||||
@@ -102,7 +111,7 @@ function DisassociateButton({
|
|||||||
|
|
||||||
{itemsToDisassociate.map(item => (
|
{itemsToDisassociate.map(item => (
|
||||||
<span key={item.id}>
|
<span key={item.id}>
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.hostname ? item.hostname : item.name}</strong>
|
||||||
<br />
|
<br />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal 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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InstanceToggle';
|
||||||
@@ -42,6 +42,7 @@ function OptionsList({
|
|||||||
renderItemChip,
|
renderItemChip,
|
||||||
isLoading,
|
isLoading,
|
||||||
i18n,
|
i18n,
|
||||||
|
displayKey,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ModalList>
|
<ModalList>
|
||||||
@@ -52,6 +53,7 @@ function OptionsList({
|
|||||||
onRemove={item => deselectItem(item)}
|
onRemove={item => deselectItem(item)}
|
||||||
isReadOnly={readOnly}
|
isReadOnly={readOnly}
|
||||||
renderItemChip={renderItemChip}
|
renderItemChip={renderItemChip}
|
||||||
|
displayKey={displayKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
@@ -70,8 +72,8 @@ function OptionsList({
|
|||||||
<CheckboxListItem
|
<CheckboxListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
name={multiple ? item.name : name}
|
name={multiple ? item[displayKey] : name}
|
||||||
label={item.name}
|
label={item[displayKey]}
|
||||||
isSelected={value.some(i => i.id === item.id)}
|
isSelected={value.some(i => i.id === item.id)}
|
||||||
onSelect={() => selectItem(item)}
|
onSelect={() => selectItem(item)}
|
||||||
onDeselect={() => deselectItem(item)}
|
onDeselect={() => deselectItem(item)}
|
||||||
@@ -91,22 +93,24 @@ const Item = shape({
|
|||||||
url: string,
|
url: string,
|
||||||
});
|
});
|
||||||
OptionsList.propTypes = {
|
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,
|
deselectItem: func.isRequired,
|
||||||
|
displayKey: string,
|
||||||
|
multiple: bool,
|
||||||
|
optionCount: number.isRequired,
|
||||||
|
options: arrayOf(Item).isRequired,
|
||||||
|
qsConfig: QSConfig.isRequired,
|
||||||
renderItemChip: func,
|
renderItemChip: func,
|
||||||
|
searchColumns: SearchColumns,
|
||||||
|
selectItem: func.isRequired,
|
||||||
|
sortColumns: SortColumns,
|
||||||
|
value: arrayOf(Item).isRequired,
|
||||||
};
|
};
|
||||||
OptionsList.defaultProps = {
|
OptionsList.defaultProps = {
|
||||||
multiple: false,
|
multiple: false,
|
||||||
renderItemChip: null,
|
renderItemChip: null,
|
||||||
searchColumns: [],
|
searchColumns: [],
|
||||||
sortColumns: [],
|
sortColumns: [],
|
||||||
|
displayKey: 'name',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(OptionsList);
|
export default withI18n()(OptionsList);
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||||
|
|
||||||
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
function ToolbarAddButton({
|
||||||
|
linkTo,
|
||||||
|
onClick,
|
||||||
|
i18n,
|
||||||
|
isDisabled,
|
||||||
|
defaultLabel = i18n._(t`Add`),
|
||||||
|
}) {
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
if (!linkTo && !onClick) {
|
if (!linkTo && !onClick) {
|
||||||
@@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
|||||||
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
|
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKebabified) {
|
if (isKebabified) {
|
||||||
return (
|
return (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@@ -23,28 +30,28 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
|||||||
to={linkTo}
|
to={linkTo}
|
||||||
onClick={!onClick ? undefined : onClick}
|
onClick={!onClick ? undefined : onClick}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (linkTo) {
|
if (linkTo) {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={i18n._(t`Add`)} position="top">
|
<Tooltip content={defaultLabel} position="top">
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={linkTo}
|
to={linkTo}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
aria-label={i18n._(t`Add`)}
|
aria-label={defaultLabel}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button variant="primary" aria-label={i18n._(t`Add`)} onClick={onClick}>
|
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import JobList from '../../components/JobList';
|
|||||||
|
|
||||||
import InstanceGroupDetails from './InstanceGroupDetails';
|
import InstanceGroupDetails from './InstanceGroupDetails';
|
||||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||||
import Instances from './Instances';
|
import InstanceList from './Instances/InstanceList';
|
||||||
|
|
||||||
function InstanceGroup({ i18n, setBreadcrumb }) {
|
function InstanceGroup({ i18n, setBreadcrumb }) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) {
|
|||||||
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/instances">
|
<Route path="/instance_groups/:id/instances">
|
||||||
<Instances />
|
<InstanceList />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/jobs">
|
<Route path="/instance_groups/:id/jobs">
|
||||||
<JobList
|
<JobList
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const instanceGroups = {
|
|||||||
|
|
||||||
const options = { data: { actions: { POST: true } } };
|
const options = { data: { actions: { POST: true } } };
|
||||||
|
|
||||||
describe('<InstanceGroupList', () => {
|
describe('<InstanceGroupList />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
test('should have data fetched and render 3 rows', async () => {
|
test('should have data fetched and render 3 rows', async () => {
|
||||||
|
|||||||
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal file
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal 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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default } from './Instances';
|
export { default as InstanceList } from './InstanceList';
|
||||||
|
export { default as InstanceListItem } from './InstanceListItem';
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export const InstanceGroup = shape({
|
|||||||
name: string.isRequired,
|
name: string.isRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Instance = shape({
|
||||||
|
id: number.isRequired,
|
||||||
|
name: string.isRequired,
|
||||||
|
});
|
||||||
|
|
||||||
export const Label = shape({
|
export const Label = shape({
|
||||||
id: number.isRequired,
|
id: number.isRequired,
|
||||||
name: string.isRequired,
|
name: string.isRequired,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const defaultContexts = {
|
|||||||
ansible_version: null,
|
ansible_version: null,
|
||||||
custom_virtualenvs: [],
|
custom_virtualenvs: [],
|
||||||
version: null,
|
version: null,
|
||||||
|
me: { is_superuser: true },
|
||||||
toJSON: () => '/config/',
|
toJSON: () => '/config/',
|
||||||
},
|
},
|
||||||
router: {
|
router: {
|
||||||
|
|||||||
Reference in New Issue
Block a user