mirror of
https://github.com/ansible/awx.git
synced 2026-01-17 12:41:19 -03:30
Associate instances to instance groups
Associate instances to instance groups. See: https://github.com/ansible/awx/issues/7801
This commit is contained in:
parent
f18d9212cb
commit
632204de83
@ -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,
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 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}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
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,
|
||||
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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
export const Instance = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
});
|
||||
|
||||
export const Label = shape({
|
||||
id: number.isRequired,
|
||||
name: string.isRequired,
|
||||
|
||||
@ -42,6 +42,7 @@ const defaultContexts = {
|
||||
ansible_version: null,
|
||||
custom_virtualenvs: [],
|
||||
version: null,
|
||||
me: { is_superuser: true },
|
||||
toJSON: () => '/config/',
|
||||
},
|
||||
router: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user