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/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/routeConfig.js b/awx/ui/src/routeConfig.js
index 339e52a228..f945e9ea79 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:
target: ${d.target.hostname}`)
+ .style('visibility', 'visible');
+ d3.select(this).transition().style('cursor', 'pointer');
+ })
+ .on('mousemove', function () {
+ tooltip
+ .style('top', event.pageY - 10 + 'px')
+ .style('left', event.pageX + 10 + 'px');
+ })
+ .on('mouseout', function () {
+ tooltip.html(``).style('visibility', 'hidden');
+ });
+
+ const node = svg
+ .append('g')
+ .attr('class', 'nodes')
+ .selectAll('g')
+ .data(graph.nodes)
+ .enter()
+ .append('g')
+ .on('mouseover', function (event, d) {
+ tooltip
+ .html(
+ `name: ${d.hostname}
type: ${d.node_type}
status: ${d.node_state}`
+ )
+ .style('visibility', 'visible');
+ // d3.select(this).transition().attr('r', 9).style('cursor', 'pointer');
+ })
+ .on('mousemove', function () {
+ tooltip
+ .style('top', event.pageY - 10 + 'px')
+ .style('left', event.pageX + 10 + 'px');
+ })
+ .on('mouseout', function () {
+ tooltip.html(``).style('visibility', 'hidden');
+ // d3.select(this).attr('r', 6);
+ });
+
+ const healthRings = node
+ .append('circle')
+ .attr('r', 8)
+ .attr('class', (d) => d.node_state)
+ .attr('stroke', d => d.node_state === 'disabled' ? '#c6c6c6' : '#50D050')
+ .attr('fill', d => d.node_state === 'disabled' ? '#c6c6c6' : '#50D050');
+
+ const nodeRings = node
+ .append('circle')
+ .attr('r', 6)
+ .attr('class', (d) => d.node_type)
+ .attr('fill', function (d) {
+ return color(d.node_type);
+ });
+ svg.call(expandGlow);
+
+ const legend = svg
+ .append('g')
+ .attr('class', 'chart-legend')
+ .selectAll('g')
+ .data(graph.nodes)
+ .enter()
+ .append('circle')
+ .attr('cx', 10)
+ .attr('cy', function (d, i) {
+ return 100 + i * 25;
+ })
+ .attr('r', 7)
+ .attr('class', (d) => d.node_type)
+ .style('fill', function (d) {
+ return color(d.node_type);
+ });
+
+ const legend_text = svg
+ .append('g')
+ .attr('class', 'chart-text')
+ .selectAll('g')
+ .data(graph.nodes)
+ .enter()
+ .append('text')
+ .attr('x', 20)
+ .attr('y', function (d, i) {
+ return 100 + i * 25;
+ })
+ .text((d) => `${d.hostname} - ${d.node_type}`)
+ .attr('text-anchor', 'left')
+ .style('alignment-baseline', 'middle');
+
+ const tooltip = d3
+ .select('#chart')
+ .append('div')
+ .attr('class', 'd3-tooltip')
+ .style('position', 'absolute')
+ .style('z-index', '10')
+ .style('visibility', 'hidden')
+ .style('padding', '15px')
+ .style('background', 'rgba(0,0,0,0.6)')
+ .style('border-radius', '5px')
+ .style('color', '#fff')
+ .style('font-family', 'sans-serif')
+ .text('a simple tooltip');
+
+ const labels = node
+ .append('text')
+ .text(function (d) {
+ return d.hostname;
+ })
+ .attr('x', 16)
+ .attr('y', 3);
+
+ simulation.nodes(graph.nodes).on('tick', ticked);
+ simulation.force('link').links(graph.links);
+
+ function ticked() {
+ link.attr('d', linkArc);
+ node.attr('transform', function (d) {
+ return 'translate(' + d.x + ',' + d.y + ')';
+ });
+ }
+
+ function linkArc(d) {
+ var dx = d.target.x - d.source.x,
+ dy = d.target.y - d.source.y,
+ dr = Math.sqrt(dx * dx + dy * dy);
+ return (
+ 'M' +
+ d.source.x +
+ ',' +
+ d.source.y +
+ 'A' +
+ dr +
+ ',' +
+ dr +
+ ' 0 0,1 ' +
+ d.target.x +
+ ',' +
+ d.target.y
+ );
+ }
+
+ function contractGlow() {
+ healthRings
+ .transition()
+ .duration(1000)
+ .attr('stroke-width', '1px')
+ .on('end', expandGlow);
+ }
+
+ function expandGlow() {
+ healthRings
+ .transition()
+ .duration(1000)
+ .attr('stroke-width', '4.5px')
+ .on('end', contractGlow);
+ }
+
+ const zoom = d3
+ .zoom()
+ .scaleExtent([1, 8])
+ .on('zoom', function (event) {
+ svg.selectAll('.links, .nodes').attr('transform', event.transform);
+ });
+
+ svg.call(zoom);
+ }, [data]);
+
+ useEffect(() => {
+ function handleResize() {
+ draw();
+ }
+
+ window.addEventListener('resize', handleResize);
+
+ handleResize();
+
+ return () => window.removeEventListener('resize', handleResize);
+ }, [draw]);
+
+ return