mirror of
https://github.com/ansible/awx.git
synced 2026-01-15 11:50:42 -03: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:
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})
|
||||
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})
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/`);
|
||||
}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
|
||||
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';
|
||||
Loading…
x
Reference in New Issue
Block a user