Add peers readonly api and instancelink constraint (#13916)

Add Disconnected link state

introspect_receptor_connections is a periodic
task that examines active receptor connections
and cross-checks it with the InstanceLink info.

Any links that should be active but are not
will be put into a Disconnected state. If
active, it will be in an Established state.

UI - Add hop creation and peers mgmt (#13922)

* add UI for mgmt peers, instance edit and add

* add peer info on detail and bug fix on detail

* remove unused chip and change peer label

* rename lookup, put Instance type disable on edit

---------

Co-authored-by: tanganellilore <lorenzo.tanagnelli@hotmail.it>
This commit is contained in:
Lorenzo Tanganelli
2023-04-27 00:34:26 +02:00
committed by Seth Foster
parent d8abd4912b
commit f7fdb7fe8d
29 changed files with 1068 additions and 83 deletions

View File

@@ -223,6 +223,10 @@ function Lookup(props) {
const Item = shape({
id: number.isRequired,
});
const InstanceItem = shape({
id: number.isRequired,
hostname: string.isRequired,
});
Lookup.propTypes = {
id: string,
@@ -230,7 +234,13 @@ Lookup.propTypes = {
modalDescription: oneOfType([string, node]),
onChange: func.isRequired,
onUpdate: func,
value: oneOfType([Item, arrayOf(Item), object]),
value: oneOfType([
Item,
arrayOf(Item),
object,
InstanceItem,
arrayOf(InstanceItem),
]),
multiple: bool,
required: bool,
onBlur: func,

View File

@@ -0,0 +1,212 @@
import React, { useCallback, useEffect } from 'react';
import { arrayOf, string, func, bool, shape } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import { FormGroup, Chip } from '@patternfly/react-core';
import { InstancesAPI } from 'api';
import { Instance } from 'types';
import { getSearchableKeys } from 'components/PaginatedTable';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import useRequest from 'hooks/useRequest';
import Popover from '../Popover';
import OptionsList from '../OptionsList';
import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage';
import FieldWithPrompt from '../FieldWithPrompt';
const QS_CONFIG = getQSConfig('instances', {
page: 1,
page_size: 5,
order_by: 'hostname',
});
function PeersLookup({
id,
value,
onChange,
tooltip,
className,
required,
history,
fieldName,
multiple,
validate,
columns,
isPromptableField,
promptId,
promptName,
formLabel,
typePeers,
instance_details,
}) {
const {
result: { instances, count, relatedSearchableKeys, searchableKeys },
request: fetchInstances,
error,
isLoading,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, history.location.search);
const peersFilter = {};
if (typePeers) {
peersFilter.not__node_type = ['control', 'hybrid'];
if (instance_details) {
if (instance_details.id) {
peersFilter.not__id = instance_details.id;
peersFilter.not__hostname = instance_details.peers;
}
}
}
const [{ data }, actionsResponse] = await Promise.all([
InstancesAPI.read(
mergeParams(params, {
...peersFilter,
})
),
InstancesAPI.readOptions(),
]);
return {
instances: data.results,
count: data.count,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
).map((val) => val.slice(0, -8)),
searchableKeys: getSearchableKeys(actionsResponse.data.actions?.GET),
};
}, [history.location, typePeers, instance_details]),
{
instances: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchInstances();
}, [fetchInstances]);
const renderLookup = () => (
<>
<Lookup
id={fieldName}
header={formLabel}
value={value}
onChange={onChange}
onUpdate={fetchInstances}
fieldName={fieldName}
validate={validate}
qsConfig={QS_CONFIG}
multiple={multiple}
required={required}
isLoading={isLoading}
label={formLabel}
renderItemChip={({ item, removeItem, canDelete }) => (
<Chip
key={item.id}
onClick={() => removeItem(item)}
isReadOnly={!canDelete}
>
{item.hostname}
</Chip>
)}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={instances}
optionCount={count}
columns={columns}
header={formLabel}
displayKey="hostname"
searchColumns={[
{
name: t`Hostname`,
key: 'hostname__icontains',
isDefault: true,
},
]}
sortColumns={[
{
name: t`Hostname`,
key: 'hostname',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={multiple}
label={formLabel}
name={fieldName}
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={(item) => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={(item) => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</>
);
return isPromptableField ? (
<FieldWithPrompt
fieldId={id}
label={formLabel}
promptId={promptId}
promptName={promptName}
tooltip={tooltip}
>
{renderLookup()}
</FieldWithPrompt>
) : (
<FormGroup
className={className}
label={formLabel}
labelIcon={tooltip && <Popover content={tooltip} />}
fieldId={id}
>
{renderLookup()}
</FormGroup>
);
}
PeersLookup.propTypes = {
id: string,
value: arrayOf(Instance).isRequired,
tooltip: string,
onChange: func.isRequired,
className: string,
required: bool,
validate: func,
multiple: bool,
fieldName: string,
columns: arrayOf(Object),
formLabel: string,
instance_details: (Instance, shape({})),
typePeers: bool,
};
PeersLookup.defaultProps = {
id: 'instances',
tooltip: '',
className: '',
required: false,
validate: () => undefined,
fieldName: 'instances',
columns: [
{
key: 'hostname',
name: t`Hostname`,
},
{
key: 'node_type',
name: t`Node Type`,
},
],
formLabel: t`Instances`,
instance_details: {},
multiple: true,
typePeers: false,
};
export default withRouter(PeersLookup);

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { InstancesAPI } from 'api';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import PeersLookup from './PeersLookup';
jest.mock('../../api');
const mockedInstances = {
count: 1,
results: [
{
id: 2,
name: 'Foo',
image: 'quay.io/ansible/awx-ee',
pull: 'missing',
},
],
};
const instances = [
{
id: 1,
hostname: 'awx_1',
type: 'instance',
url: '/api/v2/instances/1/',
related: {
named_url: '/api/v2/instances/awx_1/',
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
peers: '/api/v2/instances/1/peers/',
},
summary_fields: {
user_capabilities: {
edit: false,
},
links: [],
},
uuid: '00000000-0000-0000-0000-000000000000',
created: '2023-04-26T22:06:46.766198Z',
modified: '2023-04-26T22:06:46.766217Z',
last_seen: '2023-04-26T23:12:02.857732Z',
health_check_started: null,
health_check_pending: false,
last_health_check: '2023-04-26T23:01:13.941693Z',
errors: 'Instance received normal shutdown signal',
capacity_adjustment: '1.00',
version: '0.1.dev33237+g1fdef52',
capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 0,
jobs_running: 0,
jobs_total: 0,
cpu: '8.0',
memory: 8011055104,
cpu_capacity: 0,
mem_capacity: 0,
enabled: true,
managed_by_policy: true,
node_type: 'hybrid',
node_state: 'installed',
ip_address: null,
listener_port: 27199,
peers: [],
peers_from_control_nodes: false,
},
];
describe('PeersLookup', () => {
let wrapper;
beforeEach(() => {
InstancesAPI.read.mockResolvedValue({
data: mockedInstances,
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render successfully without instance_details (for new added instance)', async () => {
InstancesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<PeersLookup value={instances} onChange={() => {}} />
</Formik>
);
});
wrapper.update();
expect(InstancesAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('PeersLookup')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Instances"]').length).toBe(1);
expect(wrapper.find('Checkbox[aria-label="Prompt on launch"]').length).toBe(
0
);
});
test('should render successfully with instance_details for edit instance', async () => {
InstancesAPI.readOptions.mockReturnValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<PeersLookup
value={instances}
instance_details={instances[0]}
onChange={() => {}}
/>
</Formik>
);
});
wrapper.update();
expect(InstancesAPI.read).toHaveBeenCalledTimes(1);
expect(wrapper.find('PeersLookup')).toHaveLength(1);
expect(wrapper.find('FormGroup[label="Instances"]').length).toBe(1);
expect(wrapper.find('Checkbox[aria-label="Prompt on launch"]').length).toBe(
0
);
});
});

View File

@@ -8,3 +8,4 @@ export { default as ApplicationLookup } from './ApplicationLookup';
export { default as HostFilterLookup } from './HostFilterLookup';
export { default as OrganizationLookup } from './OrganizationLookup';
export { default as ExecutionEnvironmentLookup } from './ExecutionEnvironmentLookup';
export { default as PeersLookup } from './PeersLookup';

View File

@@ -125,19 +125,24 @@ const Item = shape({
name: string.isRequired,
url: string,
});
const InstanceItem = shape({
id: oneOfType([number, string]).isRequired,
hostname: string.isRequired,
url: string,
});
OptionsList.propTypes = {
deselectItem: func.isRequired,
displayKey: string,
isSelectedDraggable: bool,
multiple: bool,
optionCount: number.isRequired,
options: arrayOf(Item).isRequired,
options: oneOfType([arrayOf(Item), arrayOf(InstanceItem)]).isRequired,
qsConfig: QSConfig.isRequired,
renderItemChip: func,
searchColumns: SearchColumns,
selectItem: func.isRequired,
sortColumns: SortColumns,
value: arrayOf(Item).isRequired,
value: oneOfType([arrayOf(Item), arrayOf(InstanceItem)]).isRequired,
};
OptionsList.defaultProps = {
isSelectedDraggable: false,

View File

@@ -32,12 +32,10 @@ describe('<InstanceAdd />', () => {
await waitForElement(wrapper, 'isLoading', (el) => el.length === 0);
await act(async () => {
wrapper.find('InstanceForm').prop('handleSubmit')({
name: 'new Foo',
node_type: 'hop',
});
});
expect(InstancesAPI.create).toHaveBeenCalledWith({
name: 'new Foo',
node_type: 'hop',
});
expect(history.location.pathname).toBe('/instances/13/details');

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useHistory, useParams, Link } from 'react-router-dom';
import { t, Plural } from '@lingui/macro';
import {
Button,
@@ -116,6 +116,7 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
setBreadcrumb(instance);
}
}, [instance, setBreadcrumb]);
const { error: healthCheckError, request: fetchHealthCheck } = useRequest(
useCallback(async () => {
const { status } = await InstancesAPI.healthCheck(id);
@@ -205,13 +206,39 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
}
/>
<Detail label={t`Node Type`} value={instance.node_type} />
<Detail label={t`Host`} value={instance.ip_address} />
<Detail label={t`Instance Port`} value={instance.listener_port} />
{(isExecutionNode || isHopNode) && (
<Detail
label={t`Connection to control nodes`}
value={instance.peers_from_control_nodes ? t`On` : t`Off`}
/>
)}
{instance.related?.install_bundle && (
<Detail
label={t`Install Bundle`}
value={
<Tooltip content={t`Click to download bundle`}>
<Button
component="a"
isSmall
href={`${instance.related?.install_bundle}`}
target="_blank"
variant="secondary"
dataCy="install-bundle-download-button"
>
<DownloadIcon />
</Button>
</Tooltip>
}
/>
)}
{!isHopNode && (
<>
<Detail
label={t`Policy Type`}
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
/>
<Detail label={t`Host`} value={instance.ip_address} />
<Detail label={t`Running Jobs`} value={instance.jobs_running} />
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
{instanceGroups && (
@@ -327,9 +354,20 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
/>
)}
</DetailList>
{!isHopNode && (
<CardActionsRow>
{config?.me?.is_superuser && isK8s && isExecutionNode && (
<CardActionsRow>
{config?.me?.is_superuser && isK8s && (isExecutionNode || isHopNode) && (
<Button
ouiaId="instance-detail-edit-button"
aria-label={t`edit`}
component={Link}
to={`/instances/${id}/edit`}
>
{t`Edit`}
</Button>
)}
{config?.me?.is_superuser &&
isK8s &&
(isExecutionNode || isHopNode) && (
<RemoveInstanceButton
dataCy="remove-instance-button"
itemsToRemove={[instance]}
@@ -337,32 +375,31 @@ function InstanceDetail({ setBreadcrumb, isK8s }) {
onRemove={removeInstances}
/>
)}
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={
!config?.me?.is_superuser || instance.health_check_pending
}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={instance.health_check_pending}
spinnerAriaLabel={t`Running health check`}
>
{instance.health_check_pending
? t`Running health check`
: t`Run health check`}
</Button>
</Tooltip>
)}
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
dataCy="enable-instance"
/>
</CardActionsRow>
)}
{isExecutionNode && (
<Tooltip content={t`Run a health check on the instance`}>
<Button
isDisabled={
!config?.me?.is_superuser || instance.health_check_pending
}
variant="primary"
ouiaId="health-check-button"
onClick={fetchHealthCheck}
isLoading={instance.health_check_pending}
spinnerAriaLabel={t`Running health check`}
>
{instance.health_check_pending
? t`Running health check`
: t`Run health check`}
</Button>
</Tooltip>
)}
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchDetails}
instance={instance}
dataCy="enable-instance"
/>
</CardActionsRow>
{error && (
<AlertModal

View File

@@ -0,0 +1,105 @@
import React, { useState, useCallback, useEffect } from 'react';
import { t } from '@lingui/macro';
import { useHistory, useParams, Link } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
import useRequest from 'hooks/useRequest';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import { CardBody } from 'components/Card';
import { InstancesAPI } from 'api';
import InstanceForm from '../Shared/InstanceForm';
function InstanceEdit({ setBreadcrumb }) {
const history = useHistory();
const { id } = useParams();
const [formError, setFormError] = useState();
const detailsUrl = `/instances/${id}/details`;
const handleSubmit = async (values) => {
try {
await InstancesAPI.update(id, values);
history.push(detailsUrl);
} catch (err) {
setFormError(err);
}
};
const handleCancel = () => {
history.push(detailsUrl);
};
const {
isLoading,
error,
request: fetchDetail,
result: { instance, peers },
} = useRequest(
useCallback(async () => {
const [{ data: instance_detail }, { data: peers_detail }] =
await Promise.all([
InstancesAPI.readDetail(id),
InstancesAPI.readPeers(id),
]);
return {
instance: instance_detail,
peers: peers_detail.results,
};
}, [id]),
{
instance: {},
peers: [],
}
);
useEffect(() => {
fetchDetail();
}, [fetchDetail]);
useEffect(() => {
if (instance) {
setBreadcrumb(instance);
}
}, [instance, setBreadcrumb]);
if (isLoading) {
return (
<CardBody>
<ContentLoading />
</CardBody>
);
}
if (error) {
return (
<CardBody>
<ContentError error={error}>
{error?.response?.status === 404 && (
<span>
{t`Instance not found.`}{' '}
<Link to="/instances">{t`View all Instances.`}</Link>
</span>
)}
</ContentError>
</CardBody>
);
}
return (
<PageSection>
<Card>
<InstanceForm
instance={instance}
instance_peers={peers}
isEdit
submitError={formError}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
/>
</Card>
</PageSection>
);
}
export default InstanceEdit;

View File

@@ -0,0 +1,149 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import useDebounce from 'hooks/useDebounce';
import { InstancesAPI } from 'api';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import InstanceEdit from './InstanceEdit';
jest.mock('../../../api');
jest.mock('../../../hooks/useDebounce');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 42,
}),
}));
const instanceData = {
id: 42,
hostname: 'awx_1',
type: 'instance',
url: '/api/v2/instances/1/',
related: {
named_url: '/api/v2/instances/awx_1/',
jobs: '/api/v2/instances/1/jobs/',
instance_groups: '/api/v2/instances/1/instance_groups/',
peers: '/api/v2/instances/1/peers/',
},
summary_fields: {
user_capabilities: {
edit: false,
},
links: [],
},
uuid: '00000000-0000-0000-0000-000000000000',
created: '2023-04-26T22:06:46.766198Z',
modified: '2023-04-26T22:06:46.766217Z',
last_seen: '2023-04-26T23:12:02.857732Z',
health_check_started: null,
health_check_pending: false,
last_health_check: '2023-04-26T23:01:13.941693Z',
errors: 'Instance received normal shutdown signal',
capacity_adjustment: '1.00',
version: '0.1.dev33237+g1fdef52',
capacity: 0,
consumed_capacity: 0,
percent_capacity_remaining: 0,
jobs_running: 0,
jobs_total: 0,
cpu: '8.0',
memory: 8011055104,
cpu_capacity: 0,
mem_capacity: 0,
enabled: true,
managed_by_policy: true,
node_type: 'hybrid',
node_state: 'installed',
ip_address: null,
listener_port: 27199,
peers: [],
peers_from_control_nodes: false,
};
const instanceDataWithPeers = {
results: [instanceData],
};
const updatedInstance = {
node_type: 'hop',
peers: ['test-peer'],
};
describe('<InstanceEdit/>', () => {
let wrapper;
let history;
beforeAll(async () => {
useDebounce.mockImplementation((fn) => fn);
history = createMemoryHistory();
InstancesAPI.readDetail.mockResolvedValue({ data: instanceData });
InstancesAPI.readPeers.mockResolvedValue({ data: instanceDataWithPeers });
await act(async () => {
wrapper = mountWithContexts(
<InstanceEdit
instance={instanceData}
peers={instanceDataWithPeers}
isEdit
setBreadcrumb={() => {}}
/>,
{
context: { router: { history } },
}
);
});
expect(InstancesAPI.readDetail).toBeCalledWith(42);
});
afterAll(() => {
jest.clearAllMocks();
});
test('initially renders successfully', async () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('InstanceEdit')).toHaveLength(1);
});
test('handleSubmit should call the api and redirect to details page', async () => {
await act(async () => {
wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance);
});
expect(InstancesAPI.update).toHaveBeenCalledWith(42, updatedInstance);
expect(history.location.pathname).toEqual('/instances/42/details');
});
test('should navigate to instance details when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
});
expect(history.location.pathname).toEqual('/instances/42/details');
});
test('should navigate to instance details after successful submission', async () => {
await act(async () => {
wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance);
});
wrapper.update();
expect(wrapper.find('submitError').length).toBe(0);
expect(history.location.pathname).toEqual('/instances/42/details');
});
test('failed form submission should show an error message', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
InstancesAPI.update.mockImplementationOnce(() => Promise.reject(error));
await act(async () => {
wrapper.find('InstanceForm').invoke('handleSubmit')(updatedInstance);
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

View File

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

View File

@@ -1,17 +1,23 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { CardBody } from 'components/Card';
import PaginatedTable, {
getSearchableKeys,
HeaderCell,
HeaderRow,
ToolbarAddButton,
} from 'components/PaginatedTable';
import { getQSConfig, parseQueryString } from 'util/qs';
import DisassociateButton from 'components/DisassociateButton';
import AssociateModal from 'components/AssociateModal';
import ErrorDetail from 'components/ErrorDetail';
import AlertModal from 'components/AlertModal';
import { getQSConfig, parseQueryString, mergeParams } from 'util/qs';
import { useLocation, useParams } from 'react-router-dom';
import useRequest from 'hooks/useRequest';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import DataListToolbar from 'components/DataListToolbar';
import { InstancesAPI } from 'api';
import useExpanded from 'hooks/useExpanded';
import useSelected from 'hooks/useSelected';
import InstancePeerListItem from './InstancePeerListItem';
const QS_CONFIG = getQSConfig('peer', {
@@ -20,27 +26,35 @@ const QS_CONFIG = getQSConfig('peer', {
order_by: 'hostname',
});
function InstancePeerList() {
function InstancePeerList({ setBreadcrumb }) {
const location = useLocation();
const { id } = useParams();
const [isModalOpen, setIsModalOpen] = useState(false);
const readInstancesOptions = useCallback(
() => InstancesAPI.readOptions(id),
[id]
);
const {
isLoading,
error: contentError,
request: fetchPeers,
result: { peers, count, relatedSearchableKeys, searchableKeys },
result: { instance, peers, count, relatedSearchableKeys, searchableKeys },
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [
{ data: detail },
{
data: { results, count: itemNumber },
},
actions,
] = await Promise.all([
InstancesAPI.readDetail(id),
InstancesAPI.readPeers(id, params),
InstancesAPI.readOptions(),
]);
return {
instance: detail,
peers: results,
count: itemNumber,
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
@@ -50,6 +64,7 @@ function InstancePeerList() {
};
}, [id, location]),
{
instance: {},
peers: [],
count: 0,
relatedSearchableKeys: [],
@@ -61,18 +76,87 @@ function InstancePeerList() {
fetchPeers();
}, [fetchPeers]);
useEffect(() => {
if (instance) {
setBreadcrumb(instance);
}
}, [instance, setBreadcrumb]);
const { expanded, isAllExpanded, handleExpand, expandAll } =
useExpanded(peers);
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
useSelected(peers);
const fetchInstancesToAssociate = useCallback(
(params) =>
InstancesAPI.read(
mergeParams(params, {
...{ not__id: id },
...{ not__node_type: ['control', 'hybrid'] },
...{ not__hostname: instance.peers },
})
),
[id, instance]
);
const {
isLoading: isAssociateLoading,
request: handlePeerAssociate,
error: associateError,
} = useRequest(
useCallback(
async (instancesPeerToAssociate) => {
const selected_hostname = instancesPeerToAssociate.map(
(obj) => obj.hostname
);
const new_peers = [
...new Set([...instance.peers, ...selected_hostname]),
];
await InstancesAPI.update(instance.id, { peers: new_peers });
fetchPeers();
},
[instance, fetchPeers]
)
);
const {
isLoading: isDisassociateLoading,
request: handlePeersDiassociate,
error: disassociateError,
} = useRequest(
useCallback(async () => {
const new_peers = [];
const selected_hostname = selected.map((obj) => obj.hostname);
for (let i = 0; i < instance.peers.length; i++) {
if (!selected_hostname.includes(instance.peers[i])) {
new_peers.push(instance.peers[i]);
}
}
await InstancesAPI.update(instance.id, { peers: new_peers });
fetchPeers();
}, [instance, selected, fetchPeers])
);
const { error, dismissError } = useDismissableError(
associateError || disassociateError
);
const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return (
<CardBody>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={
isLoading || isDisassociateLoading || isAssociateLoading
}
items={peers}
itemCount={count}
pluralizedItemName={t`Peers`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
@@ -101,13 +185,36 @@ function InstancePeerList() {
renderToolbar={(props) => (
<DataListToolbar
{...props}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
(isExecutionNode || isHopNode) && (
<ToolbarAddButton
ouiaId="add-instance-peers-button"
key="associate"
defaultLabel={t`Associate`}
onClick={() => setIsModalOpen(true)}
/>
),
(isExecutionNode || isHopNode) && (
<DisassociateButton
verifyCannotDisassociate={false}
key="disassociate"
onDisassociate={handlePeersDiassociate}
itemsToDisassociate={selected}
modalTitle={t`Remove instance from peers?`}
/>
),
]}
/>
)}
renderRow={(peer, index) => (
<InstancePeerListItem
isSelected={selected.some((row) => row.id === peer.id)}
onSelect={() => handleSelect(peer)}
isExpanded={expanded.some((row) => row.id === peer.id)}
onExpand={() => handleExpand(peer)}
key={peer.id}
@@ -116,6 +223,34 @@ function InstancePeerList() {
/>
)}
/>
{isModalOpen && (
<AssociateModal
header={t`Instances`}
fetchRequest={fetchInstancesToAssociate}
isModalOpen={isModalOpen}
onAssociate={handlePeerAssociate}
onClose={() => setIsModalOpen(false)}
title={t`Select Instances`}
optionsRequest={readInstancesOptions}
displayKey="hostname"
columns={[
{ key: 'hostname', name: t`Name` },
{ key: 'node_type', name: t`Node Type` },
]}
/>
)}
{error && (
<AlertModal
isOpen={error}
onClose={dismissError}
title={t`Error!`}
variant="error"
>
{associateError && t`Failed to associate peer.`}
{disassociateError && t`Failed to remove peers.`}
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
);
}

View File

@@ -10,6 +10,8 @@ import { Detail, DetailList } from 'components/DetailList';
function InstancePeerListItem({
peerInstance,
isSelected,
onSelect,
isExpanded,
onExpand,
rowIndex,
@@ -33,7 +35,14 @@ function InstancePeerListItem({
}}
/>
)}
<Td />
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={t`Selected`}
/>
<Td id={labelId} dataLabel={t`Name`}>
<Link to={`/instances/${peerInstance.id}/details`}>
<b>{peerInstance.hostname}</b>

View File

@@ -7,6 +7,7 @@ import PersistentFilters from 'components/PersistentFilters';
import { InstanceList } from './InstanceList';
import Instance from './Instance';
import InstanceAdd from './InstanceAdd';
import InstanceEdit from './InstanceEdit';
function Instances() {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -20,8 +21,11 @@ function Instances() {
}
setBreadcrumbConfig({
'/instances': t`Instances`,
'/instances/add': t`Create new Instance`,
[`/instances/${instance.id}`]: `${instance.hostname}`,
[`/instances/${instance.id}/details`]: t`Details`,
[`/instances/${instance.id}/peers`]: t`Peers`,
[`/instances/${instance.id}/edit`]: t`Edit Instance`,
});
}, []);
@@ -30,7 +34,10 @@ function Instances() {
<ScreenHeader streamType="instance" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/instances/add">
<InstanceAdd />
<InstanceAdd setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instances/:id/edit" key="edit">
<InstanceEdit setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/instances/:id">
<Instance setBreadcrumb={buildBreadcrumbConfig} />

View File

@@ -1,6 +1,6 @@
import React from 'react';
import React, { useCallback } from 'react';
import { t } from '@lingui/macro';
import { Formik } from 'formik';
import { Formik, useField, useFormikContext } from 'formik';
import { Form, FormGroup, CardBody } from '@patternfly/react-core';
import { FormColumnLayout } from 'components/FormLayout';
import FormField, {
@@ -8,9 +8,31 @@ import FormField, {
CheckboxField,
} from 'components/FormField';
import FormActionGroup from 'components/FormActionGroup';
import AnsibleSelect from 'components/AnsibleSelect';
import { PeersLookup } from 'components/Lookup';
import { required } from 'util/validators';
function InstanceFormFields() {
const INSTANCE_TYPES = [
{ id: 'execution', name: t`Execution` },
{ id: 'hop', name: t`Hop` },
];
function InstanceFormFields({ isEdit }) {
const [instanceTypeField, instanceTypeMeta, instanceTypeHelpers] = useField({
name: 'node_type',
validate: required(t`Set a value for this field`),
});
const { setFieldValue } = useFormikContext();
const [peersField, peersMeta, peersHelpers] = useField('peers');
const handlePeersUpdate = useCallback(
(value) => {
setFieldValue('peers', value);
},
[setFieldValue]
);
return (
<>
<FormField
@@ -20,6 +42,7 @@ function InstanceFormFields() {
type="text"
validate={required(null)}
isRequired
isDisabled={isEdit}
/>
<FormField
id="instance-description"
@@ -43,13 +66,45 @@ function InstanceFormFields() {
tooltip={t`Select the port that Receptor will listen on for incoming connections. Default is 27199.`}
isRequired
/>
<FormField
id="instance-type"
<FormGroup
fieldId="instance-type"
label={t`Instance Type`}
name="node_type"
type="text"
tooltip={t`Sets the role that this instance will play within mesh topology. Default is "execution."`}
isDisabled
validated={
!instanceTypeMeta.touched || !instanceTypeMeta.error
? 'default'
: 'error'
}
helperTextInvalid={instanceTypeMeta.error}
isRequired
>
<AnsibleSelect
{...instanceTypeField}
id="node_type"
data={INSTANCE_TYPES.map((type) => ({
key: type.id,
value: type.id,
label: type.name,
}))}
onChange={(event, value) => {
instanceTypeHelpers.setValue(value);
}}
isDisabled={isEdit}
/>
</FormGroup>
<PeersLookup
helperTextInvalid={peersMeta.error}
isValid={!peersMeta.touched || !peersMeta.error}
onBlur={() => peersHelpers.setTouched()}
onChange={handlePeersUpdate}
value={peersField.value}
tooltip={t`Select the Peers Instances.`}
fieldName="peers"
formLabel={t`Peers`}
multiple
typePeers
id="peers"
isRequired
/>
<FormGroup fieldId="instance-option-checkboxes" label={t`Options`}>
<CheckboxField
@@ -64,6 +119,12 @@ function InstanceFormFields() {
label={t`Managed by Policy`}
tooltip={t`Controls whether or not this instance is managed by policy. If enabled, the instance will be available for automatic assignment to and unassignment from instance groups based on policy rules.`}
/>
<CheckboxField
id="peers_from_control_nodes"
name="peers_from_control_nodes"
label={t`Connect to control nodes`}
tooltip={t`Connect this instance to control nodes. If disabled, instance will be connected only to peers selected.`}
/>
</FormGroup>
</>
);
@@ -71,6 +132,8 @@ function InstanceFormFields() {
function InstanceForm({
instance = {},
instance_peers = [],
isEdit = false,
submitError,
handleCancel,
handleSubmit,
@@ -79,22 +142,28 @@ function InstanceForm({
<CardBody>
<Formik
initialValues={{
hostname: '',
description: '',
node_type: 'execution',
node_state: 'installed',
listener_port: 27199,
enabled: true,
managed_by_policy: true,
hostname: instance.hostname || '',
description: instance.description || '',
node_type: instance.node_type || 'execution',
node_state: instance.node_state || 'installed',
listener_port: instance.listener_port || 27199,
enabled: instance.enabled || true,
peers_from_control_nodes: instance.peers_from_control_nodes
? true
: !isEdit,
peers: instance_peers,
}}
onSubmit={(values) => {
handleSubmit(values);
handleSubmit({
...values,
peers: values.peers.map((peer) => peer.hostname || peer),
});
}}
>
{(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InstanceFormFields instance={instance} />
<InstanceFormFields isEdit={isEdit} />
<FormSubmitError error={submitError} />
<FormActionGroup
onCancel={handleCancel}

View File

@@ -94,6 +94,8 @@ describe('<InstanceForm />', () => {
listener_port: 'This is a repeat song',
node_state: 'installed',
node_type: 'execution',
peers_from_control_nodes: true,
peers: [],
});
});
});

View File

@@ -122,7 +122,7 @@ export const InstanceGroup = shape({
export const Instance = shape({
id: number.isRequired,
name: string.isRequired,
hostname: string.isRequired,
});
export const Label = shape({