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: Topology View, + path: '/topology_view', + screen: TopologyView, + }, ], }, { diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js new file mode 100644 index 0000000000..c1ac487532 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -0,0 +1,252 @@ +import React, { useEffect, useCallback } from 'react'; +import { t } from '@lingui/macro'; +import * as d3 from 'd3'; + +function MeshGraph({ data }) { + console.log('data', data); + const draw = useCallback(() => { + const margin = 80; + const getWidth = () => { + let width; + // This is in an a try/catch due to an error from jest. + // Even though the d3.select returns a valid selector with + // style function, it says it is null in the test + try { + width = + parseInt(d3.select(`#chart`).style('width'), 10) - margin || 700; + } catch (error) { + width = 700; + } + + return width; + }; + const width = getWidth(); + const height = 600; + + /* Add SVG */ + d3.selectAll(`#chart > *`).remove(); + + const svg = d3 + .select('#chart') + .append('svg') + .attr('width', `${width + margin}px`) + .attr('height', `${height + margin}px`) + .append('g') + .attr('transform', `translate(${margin}, ${margin})`); + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const simulation = d3 + .forceSimulation() + .force( + 'link', + d3.forceLink().id(function (d) { + return d.hostname; + }) + ) + .force('charge', d3.forceManyBody().strength(-350)) + .force( + 'collide', + d3.forceCollide(function (d) { + return d.node_type === 'execution' || d.node_type === 'hop' + ? 75 + : 100; + }) + ) + .force('center', d3.forceCenter(width / 2, height / 2)); + + const graph = data; + + const link = svg + .append('g') + .attr('class', 'links') + .selectAll('path') + .data(graph.links) + .enter() + .append('path') + .style('fill', 'none') + .style('stroke', '#ccc') + .style('stroke-width', '2px') + .attr('pointer-events', 'visibleStroke') + .on('mouseover', function (event, d) { + tooltip + .html(`source: ${d.source.hostname}
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
; +} + +export default MeshGraph; diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js new file mode 100644 index 0000000000..be43df6ad8 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -0,0 +1,45 @@ +import React, { useEffect, useCallback } from 'react'; + +import { t } from '@lingui/macro'; +import ScreenHeader from 'components/ScreenHeader/ScreenHeader'; +import { + PageSection, + Card, + CardHeader, + CardBody, +} from '@patternfly/react-core'; +import MeshGraph from './MeshGraph'; +import useRequest from 'hooks/useRequest'; +import { MeshAPI } from 'api'; + +function TopologyView() { + const { + result: { meshData }, + error: fetchInitialError, + request: fetchMeshVisualizer, + } = useRequest( + useCallback(async () => { + const { data } = await MeshAPI.read(); + return { + meshData: data, + }; + }, []), + { meshData: { nodes: [], links: [] } } + ); + useEffect(() => { + fetchMeshVisualizer(); + }, [fetchMeshVisualizer]); + return ( + <> + + + + + {meshData && } + + + + ); +} + +export default TopologyView; 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';