diff --git a/awx/ui/src/App.test.js b/awx/ui/src/App.test.js
index de080062fd..e1f2fb3bc3 100644
--- a/awx/ui/src/App.test.js
+++ b/awx/ui/src/App.test.js
@@ -7,6 +7,7 @@ import { mountWithContexts } from '../testUtils/enzymeHelpers';
import App, { ProtectedRoute } from './App';
jest.mock('./api');
+jest.mock('util/webWorker', () => jest.fn());
describe('', () => {
beforeEach(() => {
diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js
index a098f28781..5281ad861d 100644
--- a/awx/ui/src/api/index.js
+++ b/awx/ui/src/api/index.js
@@ -21,6 +21,7 @@ import Jobs from './models/Jobs';
import JobEvents from './models/JobEvents';
import Labels from './models/Labels';
import Me from './models/Me';
+import Mesh from './models/Mesh';
import Metrics from './models/Metrics';
import NotificationTemplates from './models/NotificationTemplates';
import Notifications from './models/Notifications';
@@ -67,6 +68,7 @@ const JobsAPI = new Jobs();
const JobEventsAPI = new JobEvents();
const LabelsAPI = new Labels();
const MeAPI = new Me();
+const MeshAPI = new Mesh();
const MetricsAPI = new Metrics();
const NotificationTemplatesAPI = new NotificationTemplates();
const NotificationsAPI = new Notifications();
@@ -114,6 +116,7 @@ export {
JobEventsAPI,
LabelsAPI,
MeAPI,
+ MeshAPI,
MetricsAPI,
NotificationTemplatesAPI,
NotificationsAPI,
diff --git a/awx/ui/src/api/models/Instances.js b/awx/ui/src/api/models/Instances.js
index 78ea59d1dd..07ee085c14 100644
--- a/awx/ui/src/api/models/Instances.js
+++ b/awx/ui/src/api/models/Instances.js
@@ -7,6 +7,7 @@ class Instances extends Base {
this.readHealthCheckDetail = this.readHealthCheckDetail.bind(this);
this.healthCheck = this.healthCheck.bind(this);
+ this.readInstanceGroup = this.readInstanceGroup.bind(this);
}
healthCheck(instanceId) {
@@ -16,6 +17,10 @@ class Instances extends Base {
readHealthCheckDetail(instanceId) {
return this.http.get(`${this.baseUrl}${instanceId}/health_check/`);
}
+
+ readInstanceGroup(instanceId) {
+ return this.http.get(`${this.baseUrl}${instanceId}/instance_groups/`);
+ }
}
export default Instances;
diff --git a/awx/ui/src/api/models/Mesh.js b/awx/ui/src/api/models/Mesh.js
new file mode 100644
index 0000000000..d7ad08067c
--- /dev/null
+++ b/awx/ui/src/api/models/Mesh.js
@@ -0,0 +1,9 @@
+import Base from '../Base';
+
+class Mesh extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/mesh_visualizer/';
+ }
+}
+export default Mesh;
diff --git a/awx/ui/src/index.test.js b/awx/ui/src/index.test.js
index 49ae9e2317..a0419c9933 100644
--- a/awx/ui/src/index.test.js
+++ b/awx/ui/src/index.test.js
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import App from './App';
jest.mock('react-dom', () => ({ render: jest.fn() }));
+jest.mock('util/webWorker', () => jest.fn());
describe('index.jsx', () => {
it('renders ok', () => {
diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js
index 339e52a228..76bb2e39a5 100644
--- a/awx/ui/src/routeConfig.js
+++ b/awx/ui/src/routeConfig.js
@@ -19,6 +19,7 @@ import Schedules from 'screens/Schedule';
import Settings from 'screens/Setting';
import Teams from 'screens/Team';
import Templates from 'screens/Template';
+import TopologyView from 'screens/TopologyView';
import Users from 'screens/User';
import WorkflowApprovals from 'screens/WorkflowApproval';
import { Jobs } from 'screens/Job';
@@ -147,6 +148,11 @@ function getRouteConfig(userProfile = {}) {
path: '/execution_environments',
screen: ExecutionEnvironments,
},
+ {
+ title: Topology View,
+ path: '/topology_view',
+ screen: TopologyView,
+ },
],
},
{
@@ -179,6 +185,7 @@ function getRouteConfig(userProfile = {}) {
deleteRoute('management_jobs');
if (userProfile?.isOrgAdmin) return routeConfig;
deleteRoute('instance_groups');
+ deleteRoute('topology_view');
if (!userProfile?.isNotificationAdmin) deleteRoute('notification_templates');
return routeConfig;
diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js
index da0dc7e536..5a7def4348 100644
--- a/awx/ui/src/routeConfig.test.js
+++ b/awx/ui/src/routeConfig.test.js
@@ -1,4 +1,5 @@
import getRouteConfig from './routeConfig';
+jest.mock('util/webWorker', () => jest.fn());
const userProfile = {
isSuperUser: false,
@@ -43,6 +44,7 @@ describe('getRouteConfig', () => {
'/instances',
'/applications',
'/execution_environments',
+ '/topology_view',
'/settings',
]);
});
@@ -71,6 +73,7 @@ describe('getRouteConfig', () => {
'/instances',
'/applications',
'/execution_environments',
+ '/topology_view',
'/settings',
]);
});
@@ -98,6 +101,7 @@ describe('getRouteConfig', () => {
'/instances',
'/applications',
'/execution_environments',
+ '/topology_view',
]);
});
@@ -233,6 +237,7 @@ describe('getRouteConfig', () => {
'/instances',
'/applications',
'/execution_environments',
+ '/topology_view',
]);
});
@@ -263,6 +268,7 @@ describe('getRouteConfig', () => {
'/instances',
'/applications',
'/execution_environments',
+ '/topology_view',
]);
});
});
diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js
new file mode 100644
index 0000000000..656edfe505
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/ContentLoading.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import { t } from '@lingui/macro';
+
+import styled from 'styled-components';
+import {
+ EmptyState as PFEmptyState,
+ Progress,
+ ProgressMeasureLocation,
+ Text,
+ TextContent,
+ TextVariants,
+} from '@patternfly/react-core';
+
+import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons';
+
+const EmptyState = styled(PFEmptyState)`
+ --pf-c-empty-state--m-lg--MaxWidth: none;
+ min-height: 250px;
+`;
+
+const TopologyIcon = styled(PFTopologyIcon)`
+ font-size: 3em;
+ fill: #6a6e73;
+`;
+
+const ContentLoading = ({ className, progress }) => (
+
+
+
+
+
+ {t`Please wait until the topology view is populated...`}
+
+
+
+);
+
+export default ContentLoading;
diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js
new file mode 100644
index 0000000000..1b287023e5
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/Header.js
@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { t } from '@lingui/macro';
+import {
+ Button,
+ PageSection,
+ PageSectionVariants,
+ Switch,
+ Title,
+ Tooltip,
+} from '@patternfly/react-core';
+
+import {
+ SearchMinusIcon,
+ SearchPlusIcon,
+ ExpandArrowsAltIcon,
+ ExpandIcon,
+} from '@patternfly/react-icons';
+
+const Header = ({
+ title,
+ handleSwitchToggle,
+ toggleState,
+ zoomIn,
+ zoomOut,
+ resetZoom,
+ zoomFit,
+ showZoomControls,
+}) => {
+ const { light } = PageSectionVariants;
+ return (
+
+
+
+
+ {title}
+
+
+
+
+ }
+ onClick={zoomIn}
+ isDisabled={!showZoomControls}
+ >
+
+
+
+
+ }
+ onClick={zoomOut}
+ isDisabled={!showZoomControls}
+ >
+
+
+
+
+ }
+ onClick={zoomFit}
+ isDisabled={!showZoomControls}
+ >
+
+
+
+
+ }
+ onClick={resetZoom}
+ isDisabled={!showZoomControls}
+ >
+
+
+
+
+ handleSwitchToggle(!toggleState)}
+ />
+
+
+
+
+ );
+};
+
+Header.propTypes = {
+ title: PropTypes.string.isRequired,
+};
+
+export default Header;
diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js
new file mode 100644
index 0000000000..5fe35beb51
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/Legend.js
@@ -0,0 +1,140 @@
+import React from 'react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import {
+ Button as PFButton,
+ DescriptionList as PFDescriptionList,
+ DescriptionListTerm,
+ DescriptionListGroup as PFDescriptionListGroup,
+ DescriptionListDescription as PFDescriptionListDescription,
+ Divider,
+ TextContent,
+ Text as PFText,
+ TextVariants,
+} from '@patternfly/react-core';
+
+import {
+ ExclamationIcon as PFExclamationIcon,
+ CheckIcon as PFCheckIcon,
+} from '@patternfly/react-icons';
+
+const Wrapper = styled.div`
+ position: absolute;
+ top: -20px;
+ left: 0;
+ padding: 10px;
+ width: 150px;
+ background-color: rgba(255, 255, 255, 0.85);
+`;
+const Button = styled(PFButton)`
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ padding: 0;
+ font-size: 11px;
+`;
+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;
+`;
+const DescriptionListGroup = styled(PFDescriptionListGroup)`
+ align-items: center;
+`;
+const Text = styled(PFText)`
+ margin: 10px 0 5px;
+`;
+
+function Legend() {
+ return (
+
+
+
+ {t`Legend`}
+
+
+ {t`Node types`}
+
+
+
+
+
+
+ {t`Control node`}
+
+
+
+
+
+
+ {t`Execution node`}
+
+
+
+
+
+
+ {t`Hybrid node`}
+
+
+
+
+
+ {t`Hop node`}
+
+
+
+ {t`Status types`}
+
+
+
+
+ }
+ isSmall
+ style={{ border: '1px solid gray', backgroundColor: '#3E8635' }}
+ />
+
+ {t`Healthy`}
+
+
+
+ } isSmall />
+
+ {t`Error`}
+
+
+
+
+
+ {t`Disabled`}
+
+
+
+ );
+}
+
+export default Legend;
diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js
new file mode 100644
index 0000000000..01ed117f7b
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/MeshGraph.js
@@ -0,0 +1,270 @@
+import React, { useEffect, useState } from 'react';
+import { useHistory } from 'react-router-dom';
+import styled from 'styled-components';
+import debounce from 'util/debounce';
+import * as d3 from 'd3';
+import Legend from './Legend';
+import Tooltip from './Tooltip';
+import ContentLoading from './ContentLoading';
+import {
+ renderStateColor,
+ renderLabelText,
+ renderNodeType,
+ renderNodeIcon,
+ redirectToDetailsPage,
+ getHeight,
+ getWidth,
+} from './utils/helpers';
+import webWorker from '../../util/webWorker';
+import {
+ DEFAULT_RADIUS,
+ DEFAULT_NODE_COLOR,
+ DEFAULT_NODE_HIGHLIGHT_COLOR,
+ DEFAULT_NODE_LABEL_TEXT_COLOR,
+ DEFAULT_FONT_SIZE,
+ SELECTOR,
+} from './constants';
+
+const Loader = styled(ContentLoading)`
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ background: white;
+`;
+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 draw = () => {
+ setShowZoomControls(false);
+ const width = getWidth(SELECTOR);
+ const height = getHeight(SELECTOR);
+
+ /* Add SVG */
+ d3.selectAll(`#chart > svg`).remove();
+ const svg = d3
+ .select('#chart')
+ .append('svg')
+ .attr('class', 'mesh-svg')
+ .attr('width', `${width}px`)
+ .attr('height', `100%`);
+ const mesh = svg.append('g').attr('class', 'mesh');
+
+ const graph = data;
+
+ /* WEB WORKER */
+ const worker = webWorker();
+ worker.postMessage({
+ nodes: graph.nodes,
+ links: graph.links,
+ });
+
+ worker.onmessage = function handleWorkerEvent(event) {
+ switch (event.data.type) {
+ case 'tick':
+ return ticked(event.data);
+ case 'end':
+ return ended(event.data);
+ default:
+ return false;
+ }
+ };
+
+ function ticked({ progress }) {
+ const calculatedPercent = Math.round(progress * 100);
+ setSimulationProgress(calculatedPercent);
+ }
+
+ function ended({ nodes, links }) {
+ // Remove loading screen
+ d3.select('.simulation-loader').style('visibility', 'hidden');
+ setShowZoomControls(true);
+ // Center the mesh
+ const simulation = d3
+ .forceSimulation(nodes)
+ .force('center', d3.forceCenter(width / 2, height / 2));
+ simulation.tick();
+ // Add links
+ mesh
+ .append('g')
+ .attr('class', `links`)
+ .attr('data-cy', 'links')
+ .selectAll('line')
+ .data(links)
+ .enter()
+ .append('line')
+ .attr('x1', (d) => d.source.x)
+ .attr('y1', (d) => d.source.y)
+ .attr('x2', (d) => d.target.x)
+ .attr('y2', (d) => d.target.y)
+ .attr('class', (_, i) => `link-${i}`)
+ .attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
+ .style('fill', 'none')
+ .style('stroke', '#ccc')
+ .style('stroke-width', '2px')
+ .attr('pointer-events', 'none')
+ .on('mouseover', function showPointer() {
+ d3.select(this).transition().style('cursor', 'pointer');
+ });
+ // add nodes
+ const node = mesh
+ .append('g')
+ .attr('class', 'nodes')
+ .attr('data-cy', 'nodes')
+ .selectAll('g')
+ .data(nodes)
+ .enter()
+ .append('g')
+ .on('mouseenter', function handleNodeHover(_, d) {
+ d3.select(this).transition().style('cursor', 'pointer');
+ highlightSiblings(d);
+ })
+ .on('mouseleave', (_, d) => {
+ deselectSiblings(d);
+ })
+ .on('click', (_, d) => {
+ setNodeDetail(d);
+ highlightSelected(d);
+ });
+
+ // node circles
+ node
+ .append('circle')
+ .attr('r', DEFAULT_RADIUS)
+ .attr('cx', (d) => d.x)
+ .attr('cy', (d) => d.y)
+ .attr('class', (d) => d.node_type)
+ .attr('class', (d) => `id-${d.id}`)
+ .attr('fill', DEFAULT_NODE_COLOR)
+ .attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR);
+
+ // node type labels
+ node
+ .append('text')
+ .text((d) => renderNodeType(d.node_type))
+ .attr('x', (d) => d.x)
+ .attr('y', (d) => d.y)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'central')
+ .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
+
+ // node hostname labels
+ const hostNames = node.append('g');
+ hostNames
+ .append('text')
+ .attr('x', (d) => d.x)
+ .attr('y', (d) => d.y + 40)
+ .text((d) => renderLabelText(d.node_state, d.hostname))
+ .attr('class', 'placeholder')
+ .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
+ .attr('text-anchor', 'middle')
+ .each(function calculateLabelWidth() {
+ // eslint-disable-next-line react/no-this-in-sfc
+ const bbox = this.getBBox();
+ // eslint-disable-next-line react/no-this-in-sfc
+ d3.select(this.parentNode)
+ .append('rect')
+ .attr('x', bbox.x)
+ .attr('y', bbox.y)
+ .attr('width', bbox.width)
+ .attr('height', bbox.height)
+ .attr('rx', 8)
+ .attr('ry', 8)
+ .style('fill', (d) => renderStateColor(d.node_state));
+ });
+ svg.selectAll('text.placeholder').remove();
+ hostNames
+ .append('text')
+ .attr('x', (d) => d.x)
+ .attr('y', (d) => d.y + 38)
+ .text((d) => renderLabelText(d.node_state, d.hostname))
+ .attr('font-size', DEFAULT_FONT_SIZE)
+ .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
+ .attr('text-anchor', 'middle');
+
+ svg.call(zoom);
+
+ function highlightSiblings(n) {
+ svg
+ .select(`circle.id-${n.id}`)
+ .attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR);
+ const immediate = links.filter(
+ (l) =>
+ n.hostname === l.source.hostname || n.hostname === l.target.hostname
+ );
+ immediate.forEach((s) => {
+ svg
+ .selectAll(`.link-${s.index}`)
+ .transition()
+ .style('stroke', '#0066CC')
+ .style('stroke-width', '3px');
+ });
+ }
+
+ function deselectSiblings(n) {
+ svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR);
+ const immediate = links.filter(
+ (l) =>
+ n.hostname === l.source.hostname || n.hostname === l.target.hostname
+ );
+ immediate.forEach((s) => {
+ svg
+ .selectAll(`.link-${s.index}`)
+ .transition()
+ .style('stroke', '#ccc')
+ .style('stroke-width', '2px');
+ });
+ }
+
+ function highlightSelected(n) {
+ if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
+ // toggle rings
+ svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
+ // show default empty state of tooltip
+ setIsNodeSelected(false);
+ setSelectedNode(null);
+ return;
+ }
+ svg.selectAll('circle').attr('stroke-width', null);
+ svg
+ .select(`circle.id-${n.id}`)
+ .attr('stroke-width', '5px')
+ .attr('stroke', '#D2D2D2');
+ setIsNodeSelected(true);
+ setSelectedNode(n);
+ }
+ }
+ };
+
+ useEffect(() => {
+ function handleResize() {
+ d3.select('.simulation-loader').style('visibility', 'visible');
+ setSelectedNode(null);
+ setIsNodeSelected(false);
+ draw();
+ }
+ window.addEventListener('resize', debounce(handleResize, 500));
+ handleResize();
+ return () => window.removeEventListener('resize', handleResize);
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+
+ {showLegend && }
+
+ redirectToDetailsPage(selectedNode, history)
+ }
+ />
+
+
+ );
+}
+
+export default MeshGraph;
diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js
new file mode 100644
index 0000000000..56bf22185d
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/Tooltip.js
@@ -0,0 +1,110 @@
+import React from 'react';
+import { t } from '@lingui/macro';
+import styled from 'styled-components';
+import {
+ Button as PFButton,
+ DescriptionList as PFDescriptionList,
+ DescriptionListTerm,
+ DescriptionListGroup as PFDescriptionListGroup,
+ DescriptionListDescription,
+ Divider,
+ TextContent,
+ Text as PFText,
+ TextVariants,
+} from '@patternfly/react-core';
+import StatusLabel from 'components/StatusLabel';
+
+const Wrapper = styled.div`
+ position: absolute;
+ top: -20px;
+ right: 0;
+ padding: 10px;
+ width: 20%;
+ background-color: rgba(255, 255, 255, 0.85);
+`;
+const Button = styled(PFButton)`
+ width: 20px;
+ height: 20px;
+ border-radius: 10px;
+ padding: 0;
+ font-size: 11px;
+`;
+const DescriptionList = styled(PFDescriptionList)`
+ gap: 0;
+`;
+const DescriptionListGroup = styled(PFDescriptionListGroup)`
+ align-items: center;
+ margin-top: 10px;
+`;
+const Text = styled(PFText)`
+ margin: 10px 0 5px;
+`;
+function Tooltip({
+ isNodeSelected,
+ renderNodeIcon,
+ nodeDetail,
+ redirectToDetailsPage,
+}) {
+ return (
+
+ {isNodeSelected === false ? (
+
+
+ {t`Details`}
+
+
+
+ {t`Click on a node icon to display the details.`}
+
+
+ ) : (
+ <>
+
+
+ {t`Details`}
+
+
+
+
+
+
+
+
+
+
+ {nodeDetail.hostname}
+
+
+
+
+ {t`Type`}
+
+ {nodeDetail.node_type} {t`node`}
+
+
+
+ {t`Status`}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default Tooltip;
diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js
new file mode 100644
index 0000000000..6ef10ca9da
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/TopologyView.js
@@ -0,0 +1,77 @@
+import React, { useEffect, useCallback, useState } from 'react';
+import { t } from '@lingui/macro';
+import { PageSection, Card, CardBody } from '@patternfly/react-core';
+import ContentError from 'components/ContentError';
+import useRequest from 'hooks/useRequest';
+import { MeshAPI } from 'api';
+import Header from './Header';
+import MeshGraph from './MeshGraph';
+import useZoom from './utils/useZoom';
+import { CHILDSELECTOR, PARENTSELECTOR } from './constants';
+
+function TopologyView() {
+ const [showLegend, setShowLegend] = useState(true);
+ const [showZoomControls, setShowZoomControls] = useState(false);
+ const {
+ isLoading,
+ result: { meshData },
+ error: fetchInitialError,
+ request: fetchMeshVisualizer,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await MeshAPI.read();
+ return {
+ meshData: data,
+ };
+ }, []),
+ { meshData: { nodes: [], links: [] } }
+ );
+ useEffect(() => {
+ fetchMeshVisualizer();
+ }, [fetchMeshVisualizer]);
+ const { zoom, zoomFit, zoomIn, zoomOut, resetZoom } = useZoom(
+ PARENTSELECTOR,
+ CHILDSELECTOR
+ );
+
+ return (
+ <>
+
+ {fetchInitialError ? (
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {!isLoading && (
+
+ )}
+
+
+
+ )}
+ >
+ );
+}
+
+export default TopologyView;
diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js
new file mode 100644
index 0000000000..d217078f6c
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/constants.js
@@ -0,0 +1,34 @@
+export const SELECTOR = '#chart';
+export const PARENTSELECTOR = '.mesh-svg';
+export const CHILDSELECTOR = '.mesh';
+export const DEFAULT_RADIUS = 16;
+export const MESH_FORCE_LAYOUT = {
+ defaultCollisionFactor: DEFAULT_RADIUS * 2 + 30,
+ defaultForceStrength: -50,
+ defaultForceBody: 15,
+ defaultForceX: 0,
+ defaultForceY: 0,
+};
+export const DEFAULT_NODE_COLOR = '#0066CC';
+export const DEFAULT_NODE_HIGHLIGHT_COLOR = '#16407C';
+export const DEFAULT_NODE_LABEL_TEXT_COLOR = 'white';
+export const DEFAULT_FONT_SIZE = '12px';
+export const LABEL_TEXT_MAX_LENGTH = 15;
+export const MARGIN = 15;
+export const NODE_STATE_COLOR_KEY = {
+ disabled: '#6A6E73',
+ healthy: '#3E8635',
+ error: '#C9190B',
+};
+export const NODE_STATE_HTML_ENTITY_KEY = {
+ disabled: '\u25EF',
+ healthy: '\u2713',
+ error: '\u0021',
+};
+
+export const NODE_TYPE_SYMBOL_KEY = {
+ hop: 'h',
+ execution: 'Ex',
+ hybrid: 'Hy',
+ control: 'C',
+};
diff --git a/awx/ui/src/screens/TopologyView/index.js b/awx/ui/src/screens/TopologyView/index.js
new file mode 100644
index 0000000000..b0983be986
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/index.js
@@ -0,0 +1 @@
+export { default } from './TopologyView';
diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js
new file mode 100644
index 0000000000..f8dee9866c
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/utils/helpers.js
@@ -0,0 +1,89 @@
+import * as d3 from 'd3';
+import { truncateString } from '../../../util/strings';
+
+import {
+ NODE_STATE_COLOR_KEY,
+ NODE_STATE_HTML_ENTITY_KEY,
+ NODE_TYPE_SYMBOL_KEY,
+ LABEL_TEXT_MAX_LENGTH,
+} from '../constants';
+
+export function renderStateColor(nodeState) {
+ return NODE_STATE_COLOR_KEY[nodeState];
+}
+
+export function renderLabelText(nodeState, name) {
+ return `${NODE_STATE_HTML_ENTITY_KEY[nodeState]} ${truncateString(
+ name,
+ LABEL_TEXT_MAX_LENGTH
+ )}`;
+}
+
+export function renderNodeType(nodeType) {
+ return NODE_TYPE_SYMBOL_KEY[nodeType];
+}
+
+export function renderNodeIcon(selectedNode) {
+ if (selectedNode) {
+ const { node_type: nodeType } = selectedNode;
+ return NODE_TYPE_SYMBOL_KEY[nodeType];
+ }
+
+ return false;
+}
+
+export function redirectToDetailsPage(selectedNode, history) {
+ const { id: nodeId } = selectedNode;
+ const constructedURL = `/instances/${nodeId}/details`;
+ history.push(constructedURL);
+}
+
+// DEBUG TOOLS
+export function getRandomInt(min, max) {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+const generateRandomLinks = (n, r) => {
+ const links = [];
+ for (let i = 0; i < r; i++) {
+ const link = {
+ source: n[getRandomInt(0, n.length - 1)].hostname,
+ target: n[getRandomInt(0, n.length - 1)].hostname,
+ };
+ links.push(link);
+ }
+ return { nodes: n, links };
+};
+
+export const generateRandomNodes = (n) => {
+ const nodes = [];
+ function getRandomType() {
+ return ['hybrid', 'execution', 'control', 'hop'][getRandomInt(0, 3)];
+ }
+ function getRandomState() {
+ return ['healthy', 'error', 'disabled'][getRandomInt(0, 2)];
+ }
+ for (let i = 0; i < n; i++) {
+ const id = i + 1;
+ const randomType = getRandomType();
+ const randomState = getRandomState();
+ const node = {
+ id,
+ hostname: `node-${id}`,
+ node_type: randomType,
+ node_state: randomState,
+ };
+ nodes.push(node);
+ }
+ return generateRandomLinks(nodes, getRandomInt(1, n - 1));
+};
+
+export function getWidth(selector) {
+ return selector ? d3.select(selector).node().clientWidth : 700;
+}
+
+export function getHeight(selector) {
+ return selector !== null ? d3.select(selector).node().clientHeight : 600;
+}
diff --git a/awx/ui/src/screens/TopologyView/utils/useZoom.js b/awx/ui/src/screens/TopologyView/utils/useZoom.js
new file mode 100644
index 0000000000..d163dd5520
--- /dev/null
+++ b/awx/ui/src/screens/TopologyView/utils/useZoom.js
@@ -0,0 +1,74 @@
+import * as d3 from 'd3';
+import { getWidth, getHeight } from './helpers';
+
+/**
+ * useZoom provides a collection of zoom behaviors/functions for D3 graphs
+ * Params: string value of parent and child classnames
+ * The following hierarchy should be followed:
+ *
+ *
+ *
+ * Returns: {
+ * zoom: d3 zoom behavior/object/function to apply on selected elements
+ * zoomIn: function that zooms in
+ * zoomOut: function that zooms out
+ * zoomFit: function that scales child element to fit within parent element
+ * resetZoom: function resets the zoom level to its initial value
+ * }
+ */
+
+export default function useZoom(parentSelector, childSelector) {
+ const zoom = d3.zoom().on('zoom', ({ transform }) => {
+ d3.select(childSelector).attr('transform', transform);
+ });
+ const zoomIn = () => {
+ d3.select(parentSelector).transition().call(zoom.scaleBy, 2);
+ };
+ const zoomOut = () => {
+ d3.select(parentSelector).transition().call(zoom.scaleBy, 0.5);
+ };
+ const resetZoom = () => {
+ const parent = d3.select(parentSelector).node();
+ const width = parent.clientWidth;
+ const height = parent.clientHeight;
+ d3.select(parentSelector)
+ .transition()
+ .duration(750)
+ .call(
+ zoom.transform,
+ d3.zoomIdentity,
+ d3
+ .zoomTransform(d3.select(parentSelector).node())
+ .invert([width / 2, height / 2])
+ );
+ };
+ const zoomFit = () => {
+ const bounds = d3.select(childSelector).node().getBBox();
+ const fullWidth = getWidth(parentSelector);
+ const fullHeight = getHeight(parentSelector);
+ const { width, height } = bounds;
+ const midX = bounds.x + width / 2;
+ const midY = bounds.y + height / 2;
+ if (width === 0 || height === 0) return; // nothing to fit
+ const scale = 0.8 / Math.max(width / fullWidth, height / fullHeight);
+ const translate = [
+ fullWidth / 2 - scale * midX,
+ fullHeight / 2 - scale * midY,
+ ];
+ const [x, y] = translate;
+ d3.select(parentSelector)
+ .transition()
+ .duration(750)
+ .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale));
+ };
+
+ return {
+ zoom,
+ zoomIn,
+ zoomOut,
+ zoomFit,
+ resetZoom,
+ };
+}
diff --git a/awx/ui/src/util/simulationWorker.js b/awx/ui/src/util/simulationWorker.js
new file mode 100644
index 0000000000..d743e2e76c
--- /dev/null
+++ b/awx/ui/src/util/simulationWorker.js
@@ -0,0 +1,34 @@
+/* eslint-disable no-undef */
+importScripts('https://d3js.org/d3-collection.v1.min.js');
+importScripts('https://d3js.org/d3-dispatch.v1.min.js');
+importScripts('https://d3js.org/d3-quadtree.v1.min.js');
+importScripts('https://d3js.org/d3-timer.v1.min.js');
+importScripts('https://d3js.org/d3-force.v1.min.js');
+
+onmessage = function calculateLayout({ data: { nodes, links } }) {
+ const simulation = d3
+ .forceSimulation(nodes)
+ .force('charge', d3.forceManyBody(15).strength(-50))
+ .force(
+ 'link',
+ d3.forceLink(links).id((d) => d.hostname)
+ )
+ .force('collide', d3.forceCollide(62))
+ .force('forceX', d3.forceX(0))
+ .force('forceY', d3.forceY(0))
+ .stop();
+
+ for (
+ let i = 0,
+ n = Math.ceil(
+ Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())
+ );
+ i < n;
+ ++i
+ ) {
+ postMessage({ type: 'tick', progress: i / n });
+ simulation.tick();
+ }
+
+ postMessage({ type: 'end', nodes, links });
+};
diff --git a/awx/ui/src/util/strings.js b/awx/ui/src/util/strings.js
index 2eee9bbe96..9fd250e450 100644
--- a/awx/ui/src/util/strings.js
+++ b/awx/ui/src/util/strings.js
@@ -17,3 +17,10 @@ export const stringIsUUID = (value) =>
/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(
value
);
+
+export const truncateString = (str, num) => {
+ if (str.length <= num) {
+ return str;
+ }
+ return `${str.slice(0, num)}...`;
+};
diff --git a/awx/ui/src/util/webWorker.js b/awx/ui/src/util/webWorker.js
new file mode 100644
index 0000000000..7babb68f38
--- /dev/null
+++ b/awx/ui/src/util/webWorker.js
@@ -0,0 +1,3 @@
+export default function webWorker() {
+ return new Worker(new URL('./simulationWorker.js', import.meta.url));
+}