diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js
index 5fe35beb51..b292786a59 100644
--- a/awx/ui/src/screens/TopologyView/Legend.js
+++ b/awx/ui/src/screens/TopologyView/Legend.js
@@ -14,8 +14,12 @@ import {
} from '@patternfly/react-core';
import {
- ExclamationIcon as PFExclamationIcon,
- CheckIcon as PFCheckIcon,
+ ExclamationIcon,
+ CheckIcon,
+ OutlinedClockIcon,
+ PlusIcon,
+ MinusIcon,
+ ResourcesEmptyIcon,
} from '@patternfly/react-icons';
const Wrapper = styled.div`
@@ -27,23 +31,20 @@ const Wrapper = styled.div`
background-color: rgba(255, 255, 255, 0.85);
`;
const Button = styled(PFButton)`
- width: 20px;
- height: 20px;
- border-radius: 10px;
- padding: 0;
- font-size: 11px;
+ &&& {
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ padding: 0;
+ font-size: 11px;
+ background-color: white;
+ border: 1px solid #ccc;
+ color: black;
+ }
`;
const DescriptionListDescription = styled(PFDescriptionListDescription)`
font-size: 11px;
`;
-const ExclamationIcon = styled(PFExclamationIcon)`
- fill: white;
- margin-left: 2px;
-`;
-const CheckIcon = styled(PFCheckIcon)`
- fill: white;
- margin-left: 2px;
-`;
const DescriptionList = styled(PFDescriptionList)`
gap: 7px;
`;
@@ -70,9 +71,7 @@ function Legend() {
-
+
{t`Control node`}
@@ -110,27 +109,133 @@ function Legend() {
}
+ icon={
+
+ }
isSmall
- style={{ border: '1px solid gray', backgroundColor: '#3E8635' }}
+ style={{ backgroundColor: '#3E8635' }}
/>
- {t`Healthy`}
+ {t`Ready`}
- } isSmall />
+
+ }
+ isSmall
+ style={{ backgroundColor: '#0066CC' }}
+ />
+
+ {t`Installed`}
+
+
+
+
+ }
+ isSmall
+ style={{ backgroundColor: '#6A6E73' }}
+ />
+
+ {t`Provisioning`}
+
+
+
+
+ }
+ isSmall
+ style={{ backgroundColor: '#6A6E73' }}
+ />
+
+ {t`Deprovisioning`}
+
+
+
+
+ }
+ isSmall
+ style={{ backgroundColor: '#F0AB00' }}
+ />
+
+ {t`Unavailable`}
+
+
+
+
+ }
+ isSmall
+ style={{ backgroundColor: '#C9190B' }}
+ />
{t`Error`}
-
+
- {t`Disabled`}
+ {t`Established`}
+
+
+
+
+
+ {t`Adding`}
+
+
+
+
+
+ {t`Removing`}
diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js
index 9e838f4377..4ba538798f 100644
--- a/awx/ui/src/screens/TopologyView/MeshGraph.js
+++ b/awx/ui/src/screens/TopologyView/MeshGraph.js
@@ -1,8 +1,13 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
+import { t } from '@lingui/macro';
import styled from 'styled-components';
import debounce from 'util/debounce';
import * as d3 from 'd3';
+import { InstancesAPI } from 'api';
+import useRequest, { useDismissableError } from 'hooks/useRequest';
+import AlertModal from 'components/AlertModal';
+import ErrorDetail from 'components/ErrorDetail';
import Legend from './Legend';
import Tooltip from './Tooltip';
import ContentLoading from './ContentLoading';
@@ -38,9 +43,38 @@ const Loader = styled(ContentLoading)`
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null);
- const [nodeDetail, setNodeDetail] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null);
const history = useHistory();
+ const {
+ result: { instance, instanceGroups },
+ error: fetchError,
+ isLoading,
+ request: fetchDetails,
+ } = useRequest(
+ useCallback(async () => {
+ const { data: instanceData } = await InstancesAPI.readDetail(
+ selectedNode.id
+ );
+ const { data: instanceGroupsData } = await InstancesAPI.readInstanceGroup(
+ selectedNode.id
+ );
+
+ return {
+ instance: instanceData,
+ instanceGroups: instanceGroupsData,
+ };
+ }, [selectedNode]),
+ {
+ result: {},
+ }
+ );
+
+ const { error: fetchInstanceError, dismissError } =
+ useDismissableError(fetchError);
+
+ useEffect(() => {
+ fetchDetails();
+ }, [selectedNode, fetchDetails]);
const draw = () => {
let width;
@@ -134,7 +168,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.attr('class', (_, i) => `link-${i}`)
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
.style('fill', 'none')
- .style('stroke', '#ccc')
+ .style('stroke', (d) =>
+ d.link_state === 'removing' ? '#C9190B' : '#CCC'
+ )
.style('stroke-width', '2px')
.style('stroke-dasharray', (d) => renderLinkState(d.link_state))
.attr('pointer-events', 'none')
@@ -158,7 +194,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
deselectSiblings(d);
})
.on('click', (_, d) => {
- setNodeDetail(d);
highlightSelected(d);
});
@@ -272,7 +307,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.selectAll(`.link-${s.index}`)
.transition()
.duration(50)
- .style('stroke', '#ccc')
+ .style('stroke', (d) =>
+ d.link_state === 'removing' ? '#C9190B' : '#CCC'
+ )
.style('stroke-width', '2px')
.attr('marker-end', 'url(#end)');
});
@@ -319,14 +356,33 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
return (
{showLegend &&
}
-
- redirectToDetailsPage(selectedNode, history)
- }
- />
+ {instance && (
+ <>
+ {fetchInstanceError && (
+
+ {t`Failed to update instance.`}
+
+
+ )}
+
+ redirectToDetailsPage(selectedNode, history)
+ }
+ />
+ >
+ )}
);
diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js
index 56bf22185d..2ae3cde2c2 100644
--- a/awx/ui/src/screens/TopologyView/Tooltip.js
+++ b/awx/ui/src/screens/TopologyView/Tooltip.js
@@ -1,6 +1,10 @@
-import React from 'react';
-import { t } from '@lingui/macro';
+import React, { useState, useCallback, useEffect } from 'react';
+import { t, Plural } from '@lingui/macro';
import styled from 'styled-components';
+import useRequest, { useDismissableError } from 'hooks/useRequest';
+import useDebounce from 'hooks/useDebounce';
+import { InstancesAPI } from 'api';
+import computeForks from 'util/computeForks';
import {
Button as PFButton,
DescriptionList as PFDescriptionList,
@@ -8,26 +12,41 @@ import {
DescriptionListGroup as PFDescriptionListGroup,
DescriptionListDescription,
Divider,
+ Progress,
+ ProgressMeasureLocation,
+ ProgressSize,
+ Slider,
TextContent,
Text as PFText,
TextVariants,
} from '@patternfly/react-core';
+import { DownloadIcon } from '@patternfly/react-icons';
+import ContentLoading from 'components/ContentLoading';
+import InstanceToggle from 'components/InstanceToggle';
import StatusLabel from 'components/StatusLabel';
+import AlertModal from 'components/AlertModal';
+import ErrorDetail from 'components/ErrorDetail';
+import { formatDateString } from 'util/dates';
const Wrapper = styled.div`
position: absolute;
top: -20px;
right: 0;
padding: 10px;
- width: 20%;
+ width: 25%;
background-color: rgba(255, 255, 255, 0.85);
`;
const Button = styled(PFButton)`
- width: 20px;
- height: 20px;
- border-radius: 10px;
- padding: 0;
- font-size: 11px;
+ &&& {
+ width: 30px;
+ height: 30px;
+ border-radius: 15px;
+ padding: 0;
+ font-size: 16px;
+ background-color: white;
+ border: 1px solid #ccc;
+ color: black;
+ }
`;
const DescriptionList = styled(PFDescriptionList)`
gap: 0;
@@ -39,12 +58,95 @@ const DescriptionListGroup = styled(PFDescriptionListGroup)`
const Text = styled(PFText)`
margin: 10px 0 5px;
`;
+
+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 renderInstanceGroups(instanceGroups) {
+ return instanceGroups.map((g) => );
+}
+
+function usedCapacity(instance) {
+ if (instance.enabled) {
+ return (
+
+ );
+ }
+ return {t`Unavailable`};
+}
+
function Tooltip({
+ fetchInstance,
isNodeSelected,
renderNodeIcon,
- nodeDetail,
+ instanceDetail,
+ instanceGroups,
+ isLoading,
redirectToDetailsPage,
}) {
+ const [forks, setForks] = useState(
+ computeForks(
+ instanceDetail.mem_capacity,
+ instanceDetail.cpu_capacity,
+ instanceDetail.capacity_adjustment
+ )
+ );
+
+ const { error: updateInstanceError, request: updateInstance } = useRequest(
+ useCallback(
+ async (values) => {
+ await InstancesAPI.update(instanceDetail.id, values);
+ },
+ [instanceDetail]
+ )
+ );
+
+ const debounceUpdateInstance = useDebounce(updateInstance, 100);
+
+ const { error: updateError, dismissError: dismissUpdateError } =
+ useDismissableError(updateInstanceError);
+
+ const handleChangeValue = (value) => {
+ const roundedValue = Math.round(value * 100) / 100;
+ setForks(
+ computeForks(
+ instanceDetail.mem_capacity,
+ instanceDetail.cpu_capacity,
+ roundedValue
+ )
+ );
+ debounceUpdateInstance({ capacity_adjustment: roundedValue });
+ };
+
+ useEffect(() => {
+ setForks(
+ computeForks(
+ instanceDetail.mem_capacity,
+ instanceDetail.cpu_capacity,
+ instanceDetail.capacity_adjustment
+ )
+ );
+ }, [instanceDetail]);
+
return (
{isNodeSelected === false ? (
@@ -62,6 +164,17 @@ function Tooltip({
) : (
<>
+ {updateError && (
+
+ {t`Failed to update instance.`}
+
+
+ )}
-
-
-
-
-
-
-
- {nodeDetail.hostname}
-
-
-
-
- {t`Type`}
-
- {nodeDetail.node_type} {t`node`}
-
-
-
- {t`Status`}
-
-
-
-
-
+ {isLoading && }
+ {!isLoading && (
+
+
+
+ {' '}
+
+ {instanceDetail.hostname}
+
+
+
+
+ {t`Instance status`}
+
+
+
+
+
+ {t`Instance type`}
+
+ {instanceDetail.node_type}
+
+
+ {instanceDetail.related?.install_bundle && (
+
+ {t`Download bundle`}
+
+
+
+
+
+
+
+
+ )}
+ {instanceDetail.ip_address && (
+
+ {t`IP address`}
+
+ {instanceDetail.ip_address}
+
+
+ )}
+ {instanceGroups && (
+
+ {t`Instance groups`}
+
+ {renderInstanceGroups(instanceGroups.results)}
+
+
+ )}
+ {instanceDetail.node_type !== 'hop' && (
+ <>
+
+ {t`Forks`}
+
+
+ {t`CPU ${instanceDetail.cpu_capacity}`}
+
+
+
+
+ {t`RAM ${instanceDetail.mem_capacity}`}
+
+
+
+
+ {t`Capacity`}
+
+ {usedCapacity(instanceDetail)}
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {t`Last modified`}
+
+ {formatDateString(instanceDetail.modified)}
+
+
+
+ {t`Last seen`}
+
+ {instanceDetail.last_seen
+ ? formatDateString(instanceDetail.last_seen)
+ : `not found`}
+
+
+
+ )}
>
)}