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:
Alex Corey 2022-08-26 09:46:40 -04:00 committed by Jeff Bradberry
parent 0465a10df5
commit 7e627e1d1e
13 changed files with 567 additions and 14 deletions

View File

@ -4919,6 +4919,7 @@ class InstanceSerializer(BaseSerializer):
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'):
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 obj.node_type != 'hop':
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})

View File

@ -3,7 +3,15 @@
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 = [
@ -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]+)/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]+)/peers/$', InstancePeersList.as_view(), name='instance_peers_list'),
re_path(r'^(?P<pk>[0-9]+)/install_bundle/$', InstanceInstallBundle.as_view(), name='instance_install_bundle'),
]

View File

@ -384,6 +384,8 @@ class InstanceDetail(RetrieveUpdateAPIView):
obj = self.get_object()
obj.set_capacity_value()
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)
return r
@ -402,6 +404,17 @@ class InstanceUnifiedJobsList(SubListAPIView):
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):
name = _("Instance's Instance Groups")

View File

@ -579,6 +579,7 @@ class InstanceAccess(BaseAccess):
return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, relationship, data=data)
def can_add(self, data):
return self.user.is_superuser
def can_change(self, obj, data):

View File

@ -19,12 +19,11 @@ def scm_revision_file(tmpdir_factory):
@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):
hostname = 'us-south-3-compute.invalid'
Instance.objects.create(hostname=hostname, node_type=node_type)
with pytest.raises(RuntimeError):
execution_node_health_check(hostname)
assert execution_node_health_check(hostname) is None
@pytest.fixture

View File

@ -18,6 +18,10 @@ class Instances extends Base {
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
}
readPeers(instanceId) {
return this.http.get(`${this.baseUrl}${instanceId}/peers`);
}
readInstanceGroup(instanceId) {
return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { t } from '@lingui/macro';
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 ContentError from 'components/ContentError';
import RoutedTabs from 'components/RoutedTabs';
import useRequest from 'hooks/useRequest';
import { SettingsAPI } from 'api';
import ContentLoading from 'components/ContentLoading';
import InstanceDetail from './InstanceDetail';
import InstancePeerList from './InstancePeers';
function Instance({ setBreadcrumb }) {
const match = useRouteMatch();
@ -25,6 +29,32 @@ function Instance({ setBreadcrumb }) {
{ 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 (
<PageSection>
<Card>
@ -34,6 +64,11 @@ function Instance({ setBreadcrumb }) {
<Route path="/instances/:id/details" key="details">
<InstanceDetail setBreadcrumb={setBreadcrumb} />
</Route>
{isK8s && (
<Route path="/instances/:id/peers" key="peers">
<InstancePeerList setBreadcrumb={setBreadcrumb} />
</Route>
)}
<Route path="*" key="not-found">
<ContentError isNotFound>
{match.params.id && (

View File

@ -1,6 +1,6 @@
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 {
Button,
@ -11,7 +11,9 @@ import {
CodeBlockCode,
Tooltip,
Slider,
Chip,
} from '@patternfly/react-core';
import { DownloadIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { useConfig } from 'contexts/Config';
@ -27,6 +29,7 @@ import ContentLoading from 'components/ContentLoading';
import { Detail, DetailList } from 'components/DetailList';
import StatusLabel from 'components/StatusLabel';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import ChipGroup from 'components/ChipGroup';
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
@ -65,10 +68,18 @@ function InstanceDetail({ setBreadcrumb }) {
isLoading,
error: contentError,
request: fetchDetails,
result: instance,
result: { instance, instanceGroups },
} = useRequest(
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') {
const { data: healthCheckData } =
@ -83,9 +94,12 @@ function InstanceDetail({ setBreadcrumb }) {
details.capacity_adjustment
)
);
return details;
return {
instance: details,
instanceGroups: results,
};
}, [id]),
{}
{ instance: {}, instanceGroups: [] }
);
useEffect(() => {
fetchDetails();
@ -127,6 +141,11 @@ function InstanceDetail({ setBreadcrumb }) {
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
const buildLinkURL = (inst) =>
inst.is_container_group
? '/instance_groups/container_group/'
: '/instance_groups/';
const { error, dismissError } = useDismissableError(
updateInstanceError || healthCheckError
);
@ -137,6 +156,7 @@ function InstanceDetail({ setBreadcrumb }) {
return <ContentLoading />;
}
const isHopNode = instance.node_type === 'hop';
return (
<CardBody>
<DetailList gutter="sm">
@ -158,12 +178,60 @@ function InstanceDetail({ setBreadcrumb }) {
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 && (
<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
label={t`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
label={t`Capacity Adjustment`}
value={

View File

@ -25,6 +25,7 @@ describe('<InstanceDetail/>', () => {
InstancesAPI.readDetail.mockResolvedValue({
data: {
related: {},
id: 1,
type: 'instance',
url: '/api/v2/instances/1/',
@ -51,6 +52,16 @@ describe('<InstanceDetail/>', () => {
node_type: 'hybrid',
},
});
InstancesAPI.readInstanceGroup.mockResolvedValue({
data: {
results: [
{
id: 1,
name: 'Foo',
},
],
},
});
InstancesAPI.readHealthCheckDetail.mockResolvedValue({
data: {
uuid: '00000000-0000-0000-0000-000000000000',

View File

@ -33,7 +33,7 @@ function InstanceList() {
const { me } = useConfig();
const {
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8 },
result: { instances, count, relatedSearchableKeys, searchableKeys, isK8s },
error: contentError,
isLoading,
request: fetchInstances,
@ -47,7 +47,7 @@ function InstanceList() {
]);
return {
instances: response.data.results,
isK8: sysSettings.data.IS_K8S,
isK8s: sysSettings.data.IS_K8S,
count: response.data.count,
actions: responseActions.data.actions,
relatedSearchableKeys: (
@ -62,7 +62,7 @@ function InstanceList() {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
isK8: false,
isK8s: false,
}
);
@ -142,7 +142,7 @@ function InstanceList() {
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
...(isK8 && me.is_superuser
...(isK8s && me.is_superuser
? [
<ToolbarAddButton
ouiaId="instances-add-button"

View 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;

View File

@ -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`}
&nbsp;
{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;

View File

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