mirror of
https://github.com/ansible/awx.git
synced 2026-03-25 12:55:04 -02:30
Adds Instance Peers Tab and update Instance Details view with more data (#12655)
* Adds InstancePeers tab and updates details view * attempt to fix failing api tests
This commit is contained in:
committed by
Jeff Bradberry
parent
0465a10df5
commit
7e627e1d1e
@@ -4919,6 +4919,7 @@ class InstanceSerializer(BaseSerializer):
|
|||||||
res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk})
|
res['instance_groups'] = self.reverse('api:instance_instance_groups_list', kwargs={'pk': obj.pk})
|
||||||
if settings.IS_K8S and obj.node_type in ('execution', 'hop'):
|
if settings.IS_K8S and obj.node_type in ('execution', 'hop'):
|
||||||
res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk})
|
res['install_bundle'] = self.reverse('api:instance_install_bundle', kwargs={'pk': obj.pk})
|
||||||
|
res['peers'] = self.reverse('api:instance_peers_list', kwargs={"pk": obj.pk})
|
||||||
if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor:
|
if self.context['request'].user.is_superuser or self.context['request'].user.is_system_auditor:
|
||||||
if obj.node_type != 'hop':
|
if obj.node_type != 'hop':
|
||||||
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
|
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
|
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from awx.api.views import InstanceList, InstanceDetail, InstanceUnifiedJobsList, InstanceInstanceGroupsList, InstanceHealthCheck, InstanceInstallBundle
|
from awx.api.views import (
|
||||||
|
InstanceList,
|
||||||
|
InstanceDetail,
|
||||||
|
InstanceUnifiedJobsList,
|
||||||
|
InstanceInstanceGroupsList,
|
||||||
|
InstanceHealthCheck,
|
||||||
|
InstanceInstallBundle,
|
||||||
|
InstancePeersList,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urls = [
|
urls = [
|
||||||
@@ -12,6 +20,7 @@ urls = [
|
|||||||
re_path(r'^(?P<pk>[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'),
|
re_path(r'^(?P<pk>[0-9]+)/jobs/$', InstanceUnifiedJobsList.as_view(), name='instance_unified_jobs_list'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'),
|
re_path(r'^(?P<pk>[0-9]+)/instance_groups/$', InstanceInstanceGroupsList.as_view(), name='instance_instance_groups_list'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'),
|
re_path(r'^(?P<pk>[0-9]+)/health_check/$', InstanceHealthCheck.as_view(), name='instance_health_check'),
|
||||||
|
re_path(r'^(?P<pk>[0-9]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'),
|
||||||
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
|
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ class InstanceDetail(RetrieveUpdateAPIView):
|
|||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
obj.set_capacity_value()
|
obj.set_capacity_value()
|
||||||
obj.save(update_fields=['capacity'])
|
obj.save(update_fields=['capacity'])
|
||||||
|
for instance in models.Instance.objects.filter(node_type__in=['control', 'hybrid']):
|
||||||
|
models.InstanceLink.objects.create(source=instance, target=obj)
|
||||||
r.data = serializers.InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj)
|
r.data = serializers.InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@@ -402,6 +404,17 @@ class InstanceUnifiedJobsList(SubListAPIView):
|
|||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
class InstancePeersList(SubListAPIView):
|
||||||
|
|
||||||
|
name = _("Instance Peers")
|
||||||
|
parent_model = models.Instance
|
||||||
|
model = models.Instance
|
||||||
|
serializer_class = serializers.InstanceSerializer
|
||||||
|
parent_access = 'read'
|
||||||
|
search_fields = {'hostname'}
|
||||||
|
relationship = 'peers'
|
||||||
|
|
||||||
|
|
||||||
class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView):
|
class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView):
|
||||||
|
|
||||||
name = _("Instance's Instance Groups")
|
name = _("Instance's Instance Groups")
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ class InstanceAccess(BaseAccess):
|
|||||||
return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data)
|
return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data)
|
||||||
|
|
||||||
def can_add(self, data):
|
def can_add(self, data):
|
||||||
|
|
||||||
return self.user.is_superuser
|
return self.user.is_superuser
|
||||||
|
|
||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ def scm_revision_file(tmpdir_factory):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize('node_type', ('control', 'hybrid'))
|
@pytest.mark.parametrize('node_type', ('control. hybrid'))
|
||||||
def test_no_worker_info_on_AWX_nodes(node_type):
|
def test_no_worker_info_on_AWX_nodes(node_type):
|
||||||
hostname = 'us-south-3-compute.invalid'
|
hostname = 'us-south-3-compute.invalid'
|
||||||
Instance.objects.create(hostname=hostname, node_type=node_type)
|
Instance.objects.create(hostname=hostname, node_type=node_type)
|
||||||
with pytest.raises(RuntimeError):
|
assert execution_node_health_check(hostname) is None
|
||||||
execution_node_health_check(hostname)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class Instances extends Base {
|
|||||||
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
|
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readPeers(instanceId) {
|
||||||
|
return this.http.get(`${this.baseUrl}${instanceId}/peers`);
|
||||||
|
}
|
||||||
|
|
||||||
readInstanceGroup(instanceId) {
|
readInstanceGroup(instanceId) {
|
||||||
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom';
|
import { Switch, Route, Redirect, Link, useRouteMatch } from 'react-router-dom';
|
||||||
@@ -6,7 +6,11 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
|||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import ContentError from 'components/ContentError';
|
import ContentError from 'components/ContentError';
|
||||||
import RoutedTabs from 'components/RoutedTabs';
|
import RoutedTabs from 'components/RoutedTabs';
|
||||||
|
import useRequest from 'hooks/useRequest';
|
||||||
|
import { SettingsAPI } from 'api';
|
||||||
|
import ContentLoading from 'components/ContentLoading';
|
||||||
import InstanceDetail from './InstanceDetail';
|
import InstanceDetail from './InstanceDetail';
|
||||||
|
import InstancePeerList from './InstancePeers';
|
||||||
|
|
||||||
function Instance({ setBreadcrumb }) {
|
function Instance({ setBreadcrumb }) {
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
@@ -25,6 +29,32 @@ function Instance({ setBreadcrumb }) {
|
|||||||
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: { isK8s },
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
request,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('system');
|
||||||
|
return data.IS_K8S;
|
||||||
|
}, []),
|
||||||
|
{ isK8s: false, isLoading: true }
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
request();
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
if (isK8s) {
|
||||||
|
tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 1 });
|
||||||
|
}
|
||||||
|
if (isLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ContentError />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -34,6 +64,11 @@ function Instance({ setBreadcrumb }) {
|
|||||||
<Route path="/instances/:id/details" key="details">
|
<Route path="/instances/:id/details" key="details">
|
||||||
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
<InstanceDetail setBreadcrumb={setBreadcrumb} />
|
||||||
</Route>
|
</Route>
|
||||||
|
{isK8s && (
|
||||||
|
<Route path="/instances/:id/peers" key="peers">
|
||||||
|
<InstancePeerList setBreadcrumb={setBreadcrumb} />
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
<Route path="*" key="not-found">
|
<Route path="*" key="not-found">
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
{match.params.id && (
|
{match.params.id && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { t, Plural } from '@lingui/macro';
|
import { t, Plural } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
CodeBlockCode,
|
CodeBlockCode,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Slider,
|
Slider,
|
||||||
|
Chip,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
import { DownloadIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { useConfig } from 'contexts/Config';
|
import { useConfig } from 'contexts/Config';
|
||||||
@@ -27,6 +29,7 @@ import ContentLoading from 'components/ContentLoading';
|
|||||||
import { Detail, DetailList } from 'components/DetailList';
|
import { Detail, DetailList } from 'components/DetailList';
|
||||||
import StatusLabel from 'components/StatusLabel';
|
import StatusLabel from 'components/StatusLabel';
|
||||||
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
|
import ChipGroup from 'components/ChipGroup';
|
||||||
|
|
||||||
const Unavailable = styled.span`
|
const Unavailable = styled.span`
|
||||||
color: var(--pf-global--danger-color--200);
|
color: var(--pf-global--danger-color--200);
|
||||||
@@ -65,10 +68,18 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
request: fetchDetails,
|
request: fetchDetails,
|
||||||
result: instance,
|
result: { instance, instanceGroups },
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const { data: details } = await InstancesAPI.readDetail(id);
|
const [
|
||||||
|
{ data: details },
|
||||||
|
{
|
||||||
|
data: { results },
|
||||||
|
},
|
||||||
|
] = await Promise.all([
|
||||||
|
InstancesAPI.readDetail(id),
|
||||||
|
InstancesAPI.readInstanceGroup(id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (details.node_type !== 'hop') {
|
if (details.node_type !== 'hop') {
|
||||||
const { data: healthCheckData } =
|
const { data: healthCheckData } =
|
||||||
@@ -83,9 +94,12 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
details.capacity_adjustment
|
details.capacity_adjustment
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return details;
|
return {
|
||||||
|
instance: details,
|
||||||
|
instanceGroups: results,
|
||||||
|
};
|
||||||
}, [id]),
|
}, [id]),
|
||||||
{}
|
{ instance: {}, instanceGroups: [] }
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDetails();
|
fetchDetails();
|
||||||
@@ -127,6 +141,11 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildLinkURL = (inst) =>
|
||||||
|
inst.is_container_group
|
||||||
|
? '/instance_groups/container_group/'
|
||||||
|
: '/instance_groups/';
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(
|
const { error, dismissError } = useDismissableError(
|
||||||
updateInstanceError || healthCheckError
|
updateInstanceError || healthCheckError
|
||||||
);
|
);
|
||||||
@@ -137,6 +156,7 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
const isHopNode = instance.node_type === 'hop';
|
const isHopNode = instance.node_type === 'hop';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList gutter="sm">
|
<DetailList gutter="sm">
|
||||||
@@ -158,12 +178,60 @@ function InstanceDetail({ setBreadcrumb }) {
|
|||||||
label={t`Policy Type`}
|
label={t`Policy Type`}
|
||||||
value={instance.managed_by_policy ? t`Auto` : t`Manual`}
|
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`Running Jobs`} value={instance.jobs_running} />
|
||||||
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
|
<Detail label={t`Total Jobs`} value={instance.jobs_total} />
|
||||||
|
{instanceGroups && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={t`Instance Groups`}
|
||||||
|
helpText={t`The Instance Groups to which this instance belongs.`}
|
||||||
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={instanceGroups.length}
|
||||||
|
ouiaId="instance-group-chips"
|
||||||
|
>
|
||||||
|
{instanceGroups.map((ig) => (
|
||||||
|
<Link
|
||||||
|
to={`${buildLinkURL(ig)}${ig.id}/details`}
|
||||||
|
key={ig.id}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
key={ig.id}
|
||||||
|
isReadOnly
|
||||||
|
ouiaId={`instance-group-${ig.id}-chip`}
|
||||||
|
>
|
||||||
|
{ig.name}
|
||||||
|
</Chip>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
isEmpty={instanceGroups.length === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Detail
|
<Detail
|
||||||
label={t`Last Health Check`}
|
label={t`Last Health Check`}
|
||||||
value={formatDateString(healthCheck?.last_health_check)}
|
value={formatDateString(healthCheck?.last_health_check)}
|
||||||
/>
|
/>
|
||||||
|
{instance.related?.install_bundle && (
|
||||||
|
<Detail
|
||||||
|
label={t`Install Bundle`}
|
||||||
|
value={
|
||||||
|
<Tooltip content={t`Click to download bundle`}>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={`${instance.related?.install_bundle}`}
|
||||||
|
target="_blank"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Detail
|
<Detail
|
||||||
label={t`Capacity Adjustment`}
|
label={t`Capacity Adjustment`}
|
||||||
value={
|
value={
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe('<InstanceDetail/>', () => {
|
|||||||
|
|
||||||
InstancesAPI.readDetail.mockResolvedValue({
|
InstancesAPI.readDetail.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
related: {},
|
||||||
id: 1,
|
id: 1,
|
||||||
type: 'instance',
|
type: 'instance',
|
||||||
url: '/api/v2/instances/1/',
|
url: '/api/v2/instances/1/',
|
||||||
@@ -51,6 +52,16 @@ describe('<InstanceDetail/>', () => {
|
|||||||
node_type: 'hybrid',
|
node_type: 'hybrid',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
InstancesAPI.readInstanceGroup.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Foo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
uuid: '00000000-0000-0000-0000-000000000000',
|
uuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function InstanceList() {
|
|||||||
const { me } = useConfig();
|
const { me } = useConfig();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 },
|
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s },
|
||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchInstances,
|
request: fetchInstances,
|
||||||
@@ -47,7 +47,7 @@ function InstanceList() {
|
|||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
instances: response.data.results,
|
instances: response.data.results,
|
||||||
isK8: sysSettings.data.IS_K8S,
|
isK8s: sysSettings.data.IS_K8S,
|
||||||
count: response.data.count,
|
count: response.data.count,
|
||||||
actions: responseActions.data.actions,
|
actions: responseActions.data.actions,
|
||||||
relatedSearchableKeys: (
|
relatedSearchableKeys: (
|
||||||
@@ -62,7 +62,7 @@ function InstanceList() {
|
|||||||
actions: {},
|
actions: {},
|
||||||
relatedSearchableKeys: [],
|
relatedSearchableKeys: [],
|
||||||
searchableKeys: [],
|
searchableKeys: [],
|
||||||
isK8: false,
|
isK8s: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ function InstanceList() {
|
|||||||
onExpandAll={expandAll}
|
onExpandAll={expandAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(isK8 && me.is_superuser
|
...(isK8s && me.is_superuser
|
||||||
? [
|
? [
|
||||||
<ToolbarAddButton
|
<ToolbarAddButton
|
||||||
ouiaId="instances-add-button"
|
ouiaId="instances-add-button"
|
||||||
|
|||||||
164
awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js
Normal file
164
awx/ui/src/screens/Instances/InstancePeers/InstancePeerList.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { CardBody } from 'components/Card';
|
||||||
|
import PaginatedTable, {
|
||||||
|
getSearchableKeys,
|
||||||
|
HeaderCell,
|
||||||
|
HeaderRow,
|
||||||
|
} from 'components/PaginatedTable';
|
||||||
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
|
import { getQSConfig } from 'util/qs';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import DataListToolbar from 'components/DataListToolbar';
|
||||||
|
import { InstancesAPI } from 'api';
|
||||||
|
import useExpanded from 'hooks/useExpanded';
|
||||||
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
|
import useSelected from 'hooks/useSelected';
|
||||||
|
import HealthCheckButton from 'components/HealthCheckButton';
|
||||||
|
import AlertModal from 'components/AlertModal';
|
||||||
|
import InstancePeerListItem from './InstancePeerListItem';
|
||||||
|
|
||||||
|
const QS_CONFIG = getQSConfig('peer', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
order_by: 'hostname',
|
||||||
|
});
|
||||||
|
|
||||||
|
function InstancePeerList() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
error: contentError,
|
||||||
|
request: fetchPeers,
|
||||||
|
result: { peers, count, relatedSearchableKeys, searchableKeys },
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const [
|
||||||
|
{
|
||||||
|
data: { results, count: itemNumber },
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
] = await Promise.all([
|
||||||
|
InstancesAPI.readPeers(id),
|
||||||
|
InstancesAPI.readOptions(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
peers: results,
|
||||||
|
count: itemNumber,
|
||||||
|
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
|
||||||
|
(val) => val.slice(0, -8)
|
||||||
|
),
|
||||||
|
searchableKeys: getSearchableKeys(actions.data.actions?.GET),
|
||||||
|
};
|
||||||
|
}, [id]),
|
||||||
|
{
|
||||||
|
peers: [],
|
||||||
|
count: 0,
|
||||||
|
relatedSearchableKeys: [],
|
||||||
|
searchableKeys: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => fetchPeers(), [fetchPeers, id]);
|
||||||
|
|
||||||
|
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
|
||||||
|
useSelected(peers.filter((i) => i.node_type !== 'hop'));
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: healthCheckError,
|
||||||
|
request: fetchHealthCheck,
|
||||||
|
isLoading: isHealthCheckLoading,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
selected
|
||||||
|
.filter(({ node_type }) => node_type !== 'hop')
|
||||||
|
.map(({ instanceId }) => InstancesAPI.healthCheck(instanceId))
|
||||||
|
);
|
||||||
|
fetchPeers();
|
||||||
|
}, [selected, fetchPeers])
|
||||||
|
);
|
||||||
|
const handleHealthCheck = async () => {
|
||||||
|
await fetchHealthCheck();
|
||||||
|
clearSelected();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error, dismissError } = useDismissableError(healthCheckError);
|
||||||
|
|
||||||
|
const { expanded, isAllExpanded, handleExpand, expandAll } =
|
||||||
|
useExpanded(peers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<PaginatedTable
|
||||||
|
contentError={contentError}
|
||||||
|
hasContentLoading={isLoading || isHealthCheckLoading}
|
||||||
|
items={peers}
|
||||||
|
itemCount={count}
|
||||||
|
pluralizedItemName={t`Peers`}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
toolbarSearchColumns={[
|
||||||
|
{
|
||||||
|
name: t`Name`,
|
||||||
|
key: 'hostname__icontains',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
toolbarSortColumns={[
|
||||||
|
{
|
||||||
|
name: t`Name`,
|
||||||
|
key: 'hostname',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderToolbar={(props) => (
|
||||||
|
<DataListToolbar
|
||||||
|
{...props}
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
onSelectAll={selectAll}
|
||||||
|
isAllExpanded={isAllExpanded}
|
||||||
|
onExpandAll={expandAll}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
additionalControls={[
|
||||||
|
<HealthCheckButton
|
||||||
|
onClick={handleHealthCheck}
|
||||||
|
selectedItems={selected}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="hostname">{t`Name`}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
|
renderRow={(peer, index) => (
|
||||||
|
<InstancePeerListItem
|
||||||
|
onSelect={() => handleSelect(peer)}
|
||||||
|
isSelected={selected.some((row) => row.id === peer.id)}
|
||||||
|
isExpanded={expanded.some((row) => row.id === peer.id)}
|
||||||
|
onExpand={() => handleExpand(peer)}
|
||||||
|
key={peer.id}
|
||||||
|
peerInstance={peer}
|
||||||
|
rowIndex={index}
|
||||||
|
fetchInstance={fetchPeers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={error}
|
||||||
|
onClose={dismissError}
|
||||||
|
title={t`Error!`}
|
||||||
|
variant="error"
|
||||||
|
>
|
||||||
|
{t`Failed to run a health check on one or more peers.`}
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstancePeerList;
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { t, Plural } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import 'styled-components/macro';
|
||||||
|
import {
|
||||||
|
Progress,
|
||||||
|
ProgressMeasureLocation,
|
||||||
|
ProgressSize,
|
||||||
|
Slider,
|
||||||
|
Tooltip,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||||
|
import { formatDateString } from 'util/dates';
|
||||||
|
import computeForks from 'util/computeForks';
|
||||||
|
import { ActionsTd, ActionItem } from 'components/PaginatedTable';
|
||||||
|
import InstanceToggle from 'components/InstanceToggle';
|
||||||
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
import useRequest, { useDismissableError } from 'hooks/useRequest';
|
||||||
|
import useDebounce from 'hooks/useDebounce';
|
||||||
|
import { InstancesAPI } from 'api';
|
||||||
|
import { useConfig } from 'contexts/Config';
|
||||||
|
import AlertModal from 'components/AlertModal';
|
||||||
|
import ErrorDetail from 'components/ErrorDetail';
|
||||||
|
import { Detail, DetailList } from 'components/DetailList';
|
||||||
|
|
||||||
|
const Unavailable = styled.span`
|
||||||
|
color: var(--pf-global--danger-color--200);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderHolder = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SliderForks = styled.div`
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function InstancePeerListItem({
|
||||||
|
peerInstance,
|
||||||
|
fetchInstances,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
isExpanded,
|
||||||
|
onExpand,
|
||||||
|
rowIndex,
|
||||||
|
}) {
|
||||||
|
const { me = {} } = useConfig();
|
||||||
|
const [forks, setForks] = useState(
|
||||||
|
computeForks(
|
||||||
|
peerInstance.mem_capacity,
|
||||||
|
peerInstance.cpu_capacity,
|
||||||
|
peerInstance.capacity_adjustment
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const labelId = `check-action-${peerInstance.id}`;
|
||||||
|
|
||||||
|
function usedCapacity(item) {
|
||||||
|
if (item.enabled) {
|
||||||
|
return (
|
||||||
|
<Progress
|
||||||
|
value={Math.round(100 - item.percent_capacity_remaining)}
|
||||||
|
measureLocation={ProgressMeasureLocation.top}
|
||||||
|
size={ProgressSize.sm}
|
||||||
|
title={t`Used capacity`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Unavailable>{t`Unavailable`}</Unavailable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateInstanceError, request: updateInstance } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async (values) => {
|
||||||
|
await InstancesAPI.update(peerInstance.id, values);
|
||||||
|
},
|
||||||
|
[peerInstance]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const { error: updateError, dismissError: dismissUpdateError } =
|
||||||
|
useDismissableError(updateInstanceError);
|
||||||
|
|
||||||
|
const debounceUpdateInstance = useDebounce(updateInstance, 200);
|
||||||
|
|
||||||
|
const handleChangeValue = (value) => {
|
||||||
|
const roundedValue = Math.round(value * 100) / 100;
|
||||||
|
setForks(
|
||||||
|
computeForks(
|
||||||
|
peerInstance.mem_capacity,
|
||||||
|
peerInstance.cpu_capacity,
|
||||||
|
roundedValue
|
||||||
|
)
|
||||||
|
);
|
||||||
|
debounceUpdateInstance({ capacity_adjustment: roundedValue });
|
||||||
|
};
|
||||||
|
const isHopNode = peerInstance.node_type === 'hop';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr
|
||||||
|
id={`peerInstance-row-${peerInstance.id}`}
|
||||||
|
ouiaId={`peerInstance-row-${peerInstance.id}`}
|
||||||
|
>
|
||||||
|
{isHopNode ? (
|
||||||
|
<Td />
|
||||||
|
) : (
|
||||||
|
<Td
|
||||||
|
expand={{
|
||||||
|
rowIndex,
|
||||||
|
isExpanded,
|
||||||
|
onToggle: onExpand,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Td
|
||||||
|
select={{
|
||||||
|
rowIndex,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
disable: isHopNode,
|
||||||
|
}}
|
||||||
|
dataLabel={t`Selected`}
|
||||||
|
/>
|
||||||
|
<Td id={labelId} dataLabel={t`Name`}>
|
||||||
|
<Link to={`/instances/${peerInstance.id}/details`}>
|
||||||
|
<b>{peerInstance.hostname}</b>
|
||||||
|
</Link>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td dataLabel={t`Status`}>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
{t`Last Health Check`}
|
||||||
|
|
||||||
|
{formatDateString(
|
||||||
|
peerInstance.last_health_check ?? peerInstance.last_seen
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StatusLabel status={peerInstance.errors ? 'error' : 'healthy'} />
|
||||||
|
</Tooltip>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td dataLabel={t`Node Type`}>{peerInstance.node_type}</Td>
|
||||||
|
{!isHopNode && (
|
||||||
|
<>
|
||||||
|
<Td dataLabel={t`Capacity Adjustment`}>
|
||||||
|
<SliderHolder data-cy="slider-holder">
|
||||||
|
<div data-cy="cpu-capacity">{t`CPU ${peerInstance.cpu_capacity}`}</div>
|
||||||
|
<SliderForks data-cy="slider-forks">
|
||||||
|
<div data-cy="number-forks">
|
||||||
|
<Plural value={forks} one="# fork" other="# forks" />
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
areCustomStepsContinuous
|
||||||
|
max={1}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
value={peerInstance.capacity_adjustment}
|
||||||
|
onChange={handleChangeValue}
|
||||||
|
isDisabled={!me?.is_superuser || !peerInstance?.enabled}
|
||||||
|
data-cy="slider"
|
||||||
|
/>
|
||||||
|
</SliderForks>
|
||||||
|
<div data-cy="mem-capacity">{t`RAM ${peerInstance.mem_capacity}`}</div>
|
||||||
|
</SliderHolder>
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<Td
|
||||||
|
dataLabel={t`Instance group used capacity`}
|
||||||
|
css="--pf-c-table--cell--MinWidth: 175px;"
|
||||||
|
>
|
||||||
|
{usedCapacity(peerInstance)}
|
||||||
|
</Td>
|
||||||
|
|
||||||
|
<ActionsTd
|
||||||
|
dataLabel={t`Actions`}
|
||||||
|
css="--pf-c-table--cell--Width: 125px"
|
||||||
|
>
|
||||||
|
<ActionItem visible>
|
||||||
|
<InstanceToggle
|
||||||
|
css="display: inline-flex;"
|
||||||
|
fetchInstances={fetchInstances}
|
||||||
|
instance={peerInstance}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tr>
|
||||||
|
{!isHopNode && (
|
||||||
|
<Tr
|
||||||
|
ouiaId={`peerInstance-row-${peerInstance.id}-expanded`}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
>
|
||||||
|
<Td colSpan={2} />
|
||||||
|
<Td colSpan={7}>
|
||||||
|
<ExpandableRowContent>
|
||||||
|
<DetailList>
|
||||||
|
<Detail
|
||||||
|
data-cy="running-jobs"
|
||||||
|
value={peerInstance.jobs_running}
|
||||||
|
label={t`Running Jobs`}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
data-cy="total-jobs"
|
||||||
|
value={peerInstance.jobs_total}
|
||||||
|
label={t`Total Jobs`}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
data-cy="policy-type"
|
||||||
|
label={t`Policy Type`}
|
||||||
|
value={peerInstance.managed_by_policy ? t`Auto` : t`Manual`}
|
||||||
|
/>
|
||||||
|
<Detail
|
||||||
|
data-cy="last-health-check"
|
||||||
|
label={t`Last Health Check`}
|
||||||
|
value={formatDateString(peerInstance.last_health_check)}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
</ExpandableRowContent>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
{updateError && (
|
||||||
|
<AlertModal
|
||||||
|
variant="error"
|
||||||
|
title={t`Error!`}
|
||||||
|
isOpen
|
||||||
|
onClose={dismissUpdateError}
|
||||||
|
>
|
||||||
|
{t`Failed to update capacity adjustment.`}
|
||||||
|
<ErrorDetail error={updateError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InstancePeerListItem;
|
||||||
1
awx/ui/src/screens/Instances/InstancePeers/index.js
Normal file
1
awx/ui/src/screens/Instances/InstancePeers/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InstancePeerList';
|
||||||
Reference in New Issue
Block a user