mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 02:19:58 -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:
parent
d8abd4912b
commit
f7fdb7fe8d
@ -5356,10 +5356,16 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
|
||||
class InstanceLinkSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = InstanceLink
|
||||
fields = ('source', 'target', 'link_state')
|
||||
fields = ('id', 'url', 'related', 'source', 'target', 'link_state')
|
||||
|
||||
source = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
|
||||
target = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
|
||||
source = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all())
|
||||
target = serializers.SlugRelatedField(slug_field="hostname", queryset=Instance.objects.all())
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(InstanceLinkSerializer, self).get_related(obj)
|
||||
res['source_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.source.id})
|
||||
res['target_instance'] = self.reverse('api:instance_detail', kwargs={'pk': obj.target.id})
|
||||
return res
|
||||
|
||||
|
||||
class InstanceNodeSerializer(BaseSerializer):
|
||||
|
||||
14
awx/api/urls/peers.py
Normal file
14
awx/api/urls/peers.py
Normal file
@ -0,0 +1,14 @@
|
||||
# Copyright (c) 2017 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from awx.api.views import PeersList, PeersDetail
|
||||
|
||||
|
||||
urls = [
|
||||
re_path(r'^$', PeersList.as_view(), name='peers_list'),
|
||||
re_path(r'^(?P<pk>[0-9]+)/$', PeersDetail.as_view(), name='peers_detail'),
|
||||
]
|
||||
|
||||
__all__ = ['urls']
|
||||
@ -84,6 +84,7 @@ from .oauth2_root import urls as oauth2_root_urls
|
||||
from .workflow_approval_template import urls as workflow_approval_template_urls
|
||||
from .workflow_approval import urls as workflow_approval_urls
|
||||
from .analytics import urls as analytics_urls
|
||||
from .peers import urls as peers_urls
|
||||
|
||||
v2_urls = [
|
||||
re_path(r'^$', ApiV2RootView.as_view(), name='api_v2_root_view'),
|
||||
@ -153,6 +154,7 @@ v2_urls = [
|
||||
re_path(r'^bulk/$', BulkView.as_view(), name='bulk'),
|
||||
re_path(r'^bulk/host_create/$', BulkHostCreateView.as_view(), name='bulk_host_create'),
|
||||
re_path(r'^bulk/job_launch/$', BulkJobLaunchView.as_view(), name='bulk_job_launch'),
|
||||
re_path(r'^peers/', include(peers_urls)),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -4350,3 +4350,17 @@ class WorkflowApprovalDeny(RetrieveAPIView):
|
||||
return Response({"error": _("This workflow step has already been approved or denied.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
obj.deny(request)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PeersList(ListAPIView):
|
||||
name = _("Peers")
|
||||
model = models.InstanceLink
|
||||
serializer_class = serializers.InstanceLinkSerializer
|
||||
search_fields = ('source', 'target', 'link_state')
|
||||
|
||||
|
||||
class PeersDetail(RetrieveAPIView):
|
||||
name = _("Peers Detail")
|
||||
always_allow_superuser = True
|
||||
model = models.InstanceLink
|
||||
serializer_class = serializers.InstanceLinkSerializer
|
||||
|
||||
@ -129,6 +129,7 @@ class ApiVersionRootView(APIView):
|
||||
data['mesh_visualizer'] = reverse('api:mesh_visualizer_view', request=request)
|
||||
data['bulk'] = reverse('api:bulk', request=request)
|
||||
data['analytics'] = reverse('api:analytics_root_view', request=request)
|
||||
data['peers'] = reverse('api:peers_list', request=request)
|
||||
return Response(data)
|
||||
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ from awx.main.models import (
|
||||
Host,
|
||||
Instance,
|
||||
InstanceGroup,
|
||||
InstanceLink,
|
||||
Inventory,
|
||||
InventorySource,
|
||||
InventoryUpdate,
|
||||
@ -2949,6 +2950,22 @@ class WorkflowApprovalTemplateAccess(BaseAccess):
|
||||
return self.model.objects.filter(workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role'))
|
||||
|
||||
|
||||
class PeersAccess(BaseAccess):
|
||||
model = InstanceLink
|
||||
|
||||
def can_add(self, data):
|
||||
return False
|
||||
|
||||
def can_change(self, obj, data):
|
||||
return False
|
||||
|
||||
def can_delete(self, obj):
|
||||
return True
|
||||
|
||||
def can_copy(self, obj):
|
||||
return False
|
||||
|
||||
|
||||
for cls in BaseAccess.__subclasses__():
|
||||
access_registry[cls.model] = cls
|
||||
access_registry[UnpartitionedJobEvent] = UnpartitionedJobEventAccess
|
||||
|
||||
38
awx/main/migrations/0183_auto_20230501_2000.py
Normal file
38
awx/main/migrations/0183_auto_20230501_2000.py
Normal file
@ -0,0 +1,38 @@
|
||||
# Generated by Django 3.2.16 on 2023-05-01 20:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.expressions
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0182_constructed_inventory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='instancelink',
|
||||
options={'ordering': ('id',)},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='instance',
|
||||
name='peers_from_control_nodes',
|
||||
field=models.BooleanField(default=False, help_text='If True, control plane cluster nodes should automatically peer to it.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='instancelink',
|
||||
name='link_state',
|
||||
field=models.CharField(
|
||||
choices=[('adding', 'Adding'), ('established', 'Established'), ('disconnected', 'Disconnected'), ('removing', 'Removing')],
|
||||
default='disconnected',
|
||||
help_text='Indicates the current life cycle stage of this peer link.',
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='instancelink',
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(('source', django.db.models.expressions.F('target')), _negated=True), name='source_and_target_can_not_be_equal'
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,17 +0,0 @@
|
||||
# Generated by Django 3.2.16 on 2023-04-25 19:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('main', '0182_constructed_inventory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='instance',
|
||||
name='peers_from_control_nodes',
|
||||
field=models.BooleanField(default=False, help_text='If True, control plane cluster nodes should automatically peer to it.'),
|
||||
),
|
||||
]
|
||||
@ -67,14 +67,20 @@ class InstanceLink(BaseModel):
|
||||
class States(models.TextChoices):
|
||||
ADDING = 'adding', _('Adding')
|
||||
ESTABLISHED = 'established', _('Established')
|
||||
DISCONNECTED = 'disconnected', _('Disconnected')
|
||||
REMOVING = 'removing', _('Removing')
|
||||
|
||||
link_state = models.CharField(
|
||||
choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
|
||||
choices=States.choices, default=States.DISCONNECTED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('source', 'target')
|
||||
ordering = ("id",)
|
||||
constraints = [models.CheckConstraint(check=~models.Q(source=models.F('target')), name='source_and_target_can_not_be_equal')]
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
return reverse('api:peers_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
|
||||
class Instance(HasPolicyEditsMixin, BaseModel):
|
||||
|
||||
@ -679,6 +679,7 @@ RECEPTOR_CONFIG_STARTER = (
|
||||
def write_receptor_config(force=False):
|
||||
"""
|
||||
only control nodes will run this
|
||||
force=True means to call receptorctl reload
|
||||
"""
|
||||
lock = FileLock(__RECEPTOR_CONF_LOCKFILE)
|
||||
with lock:
|
||||
|
||||
@ -512,6 +512,30 @@ def execution_node_health_check(node):
|
||||
return data
|
||||
|
||||
|
||||
@task(queue=get_task_queuename)
|
||||
def inspect_receptor_connections():
|
||||
ctl = get_receptor_ctl()
|
||||
mesh_status = ctl.simple_command('status')
|
||||
|
||||
# detect active/inactive receptor links
|
||||
from awx.main.models import InstanceLink
|
||||
|
||||
all_links = InstanceLink.objects.all()
|
||||
active_receptor_conns = mesh_status['KnownConnectionCosts']
|
||||
update_links = []
|
||||
for link in all_links:
|
||||
if link.target.hostname in active_receptor_conns.get(link.source.hostname, {}):
|
||||
if link.link_state is not InstanceLink.States.ESTABLISHED:
|
||||
link.link_state = InstanceLink.States.ESTABLISHED
|
||||
update_links.append(link)
|
||||
else:
|
||||
if link.link_state is not InstanceLink.States.DISCONNECTED:
|
||||
link.link_state = InstanceLink.States.DISCONNECTED
|
||||
update_links.append(link)
|
||||
|
||||
InstanceLink.objects.bulk_update(update_links, ['link_state'])
|
||||
|
||||
|
||||
def inspect_execution_nodes(instance_list):
|
||||
with advisory_lock('inspect_execution_nodes_lock', wait=False):
|
||||
node_lookup = {inst.hostname: inst for inst in instance_list}
|
||||
|
||||
@ -84,5 +84,6 @@ def test_custom_hostname_regex(post, admin_user):
|
||||
"hostname": value[0],
|
||||
"node_type": "execution",
|
||||
"node_state": "installed",
|
||||
"peers": [],
|
||||
}
|
||||
post(url=url, user=admin_user, data=data, expect=value[1])
|
||||
|
||||
@ -463,6 +463,7 @@ CELERYBEAT_SCHEDULE = {
|
||||
'schedule': timedelta(seconds=CLUSTER_NODE_HEARTBEAT_PERIOD),
|
||||
'options': {'expires': 50},
|
||||
},
|
||||
'inspect_receptor_connections': {'task': 'awx.main.tasks.system.inspect_receptor_connections', 'schedule': timedelta(seconds=20)},
|
||||
'gather_analytics': {'task': 'awx.main.tasks.system.gather_analytics', 'schedule': timedelta(minutes=5)},
|
||||
'task_manager': {'task': 'awx.main.scheduler.tasks.task_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}},
|
||||
'dependency_manager': {'task': 'awx.main.scheduler.tasks.dependency_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}},
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user