mirror of
https://github.com/ansible/awx.git
synced 2026-02-23 22:16:00 -03:30
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:
committed by
Seth Foster
parent
d8abd4912b
commit
f7fdb7fe8d
@@ -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,
|
||||
|
||||
212
awx/ui/src/components/Lookup/PeersLookup.js
Executable file
212
awx/ui/src/components/Lookup/PeersLookup.js
Executable 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);
|
||||
137
awx/ui/src/components/Lookup/PeersLookup.test.js
Executable file
137
awx/ui/src/components/Lookup/PeersLookup.test.js
Executable 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
105
awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.js
Normal file
105
awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.js
Normal 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;
|
||||
149
awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js
Normal file
149
awx/ui/src/screens/Instances/InstanceEdit/InstanceEdit.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
1
awx/ui/src/screens/Instances/InstanceEdit/index.js
Normal file
1
awx/ui/src/screens/Instances/InstanceEdit/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './InstanceEdit';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user