From 1246b14e7eb841494d6d8fe046b40bd8dba285ac Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 4 Jan 2022 07:34:03 -0800 Subject: [PATCH 01/41] WIP add network visualizer to Controller UI. --- awx/ui/src/api/index.js | 3 + awx/ui/src/api/models/Mesh.js | 9 + awx/ui/src/routeConfig.js | 6 + awx/ui/src/screens/TopologyView/MeshGraph.js | 252 ++++++++++++++++++ .../src/screens/TopologyView/TopologyView.js | 45 ++++ awx/ui/src/screens/TopologyView/index.js | 1 + 6 files changed, 316 insertions(+) create mode 100644 awx/ui/src/api/models/Mesh.js create mode 100644 awx/ui/src/screens/TopologyView/MeshGraph.js create mode 100644 awx/ui/src/screens/TopologyView/TopologyView.js create mode 100644 awx/ui/src/screens/TopologyView/index.js 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'; From 826a069be01524ef253c48db8c53fa3666a7acd0 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 18 Jan 2022 09:41:16 -0800 Subject: [PATCH 02/41] Highlight immediate siblings on hover. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 270 ++++++++++++++++--- 1 file changed, 226 insertions(+), 44 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index c1ac487532..52747d0a81 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -2,8 +2,102 @@ import React, { useEffect, useCallback } from 'react'; import { t } from '@lingui/macro'; import * as d3 from 'd3'; -function MeshGraph({ data }) { - console.log('data', data); +// function MeshGraph({ data }) { +function MeshGraph() { + const data = { + nodes: [ + { + hostname: "aapc1.local", + node_state: "healthy", + node_type: "control", + id: 1 + }, + { + hostname: "aapc2.local", + node_type: "control", + node_state: "disabled", + id: 2 + }, + { + hostname: "aapc3.local", + node_type: "control", + node_state: "healthy", + id: 3 + }, + { + hostname: "aape1.local", + node_type: "execution", + node_state: "error", + id: 4 + }, + { + hostname: "aape2.local", + node_type: "execution", + node_state: "error", + id: 5 + }, + { + hostname: "aape3.local", + node_type: "execution", + node_state: "healthy", + id: 6 + }, + { + hostname: "aape4.local", + node_type: "execution", + node_state: "healthy", + id: 7 + }, + { + hostname: "aaph1.local", + node_type: "hop", + node_state: "disabled", + id: 8 + }, + { + hostname: "aaph2.local", + node_type: "hop", + node_state: "healthy", + id: 9 + }, + { + hostname: "aaph3.local", + node_type: "hop", + node_state: "error", + id: 10 + } + ], + links: [ + { source: "aapc1.local", target: "aapc2.local" }, + { source: "aapc1.local", target: "aapc3.local" }, + { source: "aapc1.local", target: "aape1.local" }, + { source: "aapc1.local", target: "aape2.local" }, + + { source: "aapc2.local", target: "aapc3.local" }, + { source: "aapc2.local", target: "aape1.local" }, + { source: "aapc2.local", target: "aape2.local" }, + + { source: "aapc3.local", target: "aape1.local" }, + { source: "aapc3.local", target: "aape2.local" }, + + { source: "aape3.local", target: "aaph1.local" }, + { source: "aape3.local", target: "aaph2.local" }, + + { source: "aape4.local", target: "aaph3.local" }, + + { source: "aaph1.local", target: "aapc1.local" }, + { source: "aaph1.local", target: "aapc2.local" }, + { source: "aaph1.local", target: "aapc3.local" }, + + { source: "aaph2.local", target: "aapc1.local" }, + { source: "aaph2.local", target: "aapc2.local" }, + { source: "aaph2.local", target: "aapc3.local" }, + + { source: "aaph3.local", target: "aaph1.local" }, + { source: "aaph3.local", target: "aaph2.local" } + ] + }; + const draw = useCallback(() => { const margin = 80; const getWidth = () => { @@ -22,6 +116,15 @@ function MeshGraph({ data }) { }; const width = getWidth(); const height = 600; + const defaultRadius = 6; + const highlightRadius = 9; + + const zoom = d3 + .zoom() + .scaleExtent([1, 8]) + .on('zoom', function (event) { + svg.selectAll('.links, .nodes').attr('transform', event.transform); + }); /* Add SVG */ d3.selectAll(`#chart > *`).remove(); @@ -32,9 +135,11 @@ function MeshGraph({ data }) { .attr('width', `${width + margin}px`) .attr('height', `${height + margin}px`) .append('g') - .attr('transform', `translate(${margin}, ${margin})`); + .attr('transform', `translate(${margin}, ${margin})`) + .call(zoom); const color = d3.scaleOrdinal(d3.schemeCategory10); + const graph = data; const simulation = d3 .forceSimulation() @@ -55,32 +160,31 @@ function MeshGraph({ data }) { ) .force('center', d3.forceCenter(width / 2, height / 2)); - const graph = data; - const link = svg .append('g') - .attr('class', 'links') + .attr('class', `links`) .selectAll('path') .data(graph.links) .enter() .append('path') + .attr('class', (d, i) => `link-${i}`) .style('fill', 'none') .style('stroke', '#ccc') .style('stroke-width', '2px') - .attr('pointer-events', 'visibleStroke') + .attr('pointer-events', 'none') .on('mouseover', function (event, d) { - tooltip - .html(`source: ${d.source.hostname}
target: ${d.target.hostname}`) - .style('visibility', 'visible'); + // 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'); + // tooltip + // .style('top', event.pageY - 10 + 'px') + // .style('left', event.pageX + 10 + 'px'); }) .on('mouseout', function () { - tooltip.html(``).style('visibility', 'hidden'); + // tooltip.html(``).style('visibility', 'hidden'); }); const node = svg @@ -90,52 +194,64 @@ function MeshGraph({ data }) { .data(graph.nodes) .enter() .append('g') - .on('mouseover', function (event, d) { + .on('mouseenter', function (event, d) { + d3.select(this).transition().style('cursor', 'pointer'); + highlightSiblings(d) tooltip .html( - `name: ${d.hostname}
type: ${d.node_type}
status: ${d.node_state}` + `

Details


name: ${d.hostname}
type: ${d.node_type}
status: ${d.node_state}
Click on a node to view the details` ) - .style('visibility', 'visible'); + .style('visibility', 'visible') + // .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'); + // tooltip + // .style('top', event.pageY - 10 + 'px') + // .style('left', event.pageX + 10 + 'px'); }) - .on('mouseout', function () { + .on('mouseleave', function (event, d) { + deselectSiblings(d) 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'); - + .attr('stroke', d => renderHealthColor(d.node_state)) + .attr('fill', d => renderHealthColor(d.node_state)); + const nodeRings = node .append('circle') - .attr('r', 6) + .attr('r', defaultRadius) .attr('class', (d) => d.node_type) + .attr('class', (d) => `id-${d.id}`) .attr('fill', function (d) { return color(d.node_type); - }); + }) + .attr('stroke', 'white'); svg.call(expandGlow); const legend = svg + .append('text') + .attr('x', 10) + .attr('y', 20) + .text('Legend') + + svg .append('g') - .attr('class', 'chart-legend') .selectAll('g') + .attr('class', 'chart-legend') .data(graph.nodes) .enter() .append('circle') .attr('cx', 10) .attr('cy', function (d, i) { - return 100 + i * 25; + return 50 + i * 25; }) - .attr('r', 7) + .attr('r', defaultRadius) .attr('class', (d) => d.node_type) .style('fill', function (d) { return color(d.node_type); @@ -150,7 +266,7 @@ function MeshGraph({ data }) { .append('text') .attr('x', 20) .attr('y', function (d, i) { - return 100 + i * 25; + return 50 + i * 25; }) .text((d) => `${d.hostname} - ${d.node_type}`) .attr('text-anchor', 'left') @@ -161,14 +277,20 @@ function MeshGraph({ data }) { .append('div') .attr('class', 'd3-tooltip') .style('position', 'absolute') + .style('top', '200px') + .style('right', '40px') .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('border', '1px solid #e6e6e6') + // .style('box-shadow', '5px 5px 5px #e6e6e6') + .style('max-width', '15%') + // .style('background', 'rgba(0,0,0,0.6)') + // .style('border-radius', '5px') + // .style('color', '#fff') .style('font-family', 'sans-serif') - .text('a simple tooltip'); + .style('color', '#e6e6e') + .text(''); const labels = node .append('text') @@ -183,6 +305,7 @@ function MeshGraph({ data }) { function ticked() { link.attr('d', linkArc); + node.attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')'; }); @@ -209,7 +332,8 @@ function MeshGraph({ data }) { } function contractGlow() { - healthRings + svg + .selectAll('.healthy') .transition() .duration(1000) .attr('stroke-width', '1px') @@ -217,21 +341,79 @@ function MeshGraph({ data }) { } function expandGlow() { - healthRings + svg + .selectAll('.healthy') .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); - }); + function renderHealthColor(nodeState) { + const colorKey = { + 'disabled': '#c6c6c6', + 'healthy': '#50D050', + 'error': '#ff6766' + }; + return colorKey[nodeState]; + } - svg.call(zoom); + function renderNodeClass(nodeState) { + const colorKey = { + 'disabled': 'node-disabled', + 'healthy': 'node-healthy', + 'error': 'node-error' + }; + return colorKey[nodeState]; + } + + function highlightSiblings(node) { + setTimeout(function() { + svg.selectAll(`id-${node.id}`) + .attr('r', highlightRadius); + const immediate = graph.links.filter(link => node.hostname === link.source.hostname || node.hostname === link.target.hostname); + immediate.forEach(s => { + const links = svg.selectAll(`.link-${s.index}`) + .transition() + .style('stroke', '#6e6e6e') + const sourceNodes = svg.selectAll(`.id-${s.source.id}`) + .transition() + .attr('r', highlightRadius) + const targetNodes = svg.selectAll(`.id-${s.target.id}`) + .transition() + .attr('r', highlightRadius) + }) + + }, 0) + + } + + function deselectSiblings(node) { + svg.selectAll(`id-${node.id}`) + .attr('r', defaultRadius); + const immediate = graph.links.filter(link => node.hostname === link.source.hostname || node.hostname === link.target.hostname); + immediate.forEach(s => { + const links = svg.selectAll(`.link-${s.index}`) + .transition() + .style('stroke', '#ccc') + svg.selectAll(`.id-${s.source.id}`) + .transition() + .attr('r', defaultRadius) + svg.selectAll(`.id-${s.target.id}`) + .transition() + .attr('r', defaultRadius) + }) + } + // const zoom = d3 + // .zoom() + // .scaleExtent([1, 8]) + // .on('zoom', function (event) { + // svg.selectAll('.links, .nodes').attr('transform', event.transform); + // }); + + // svg.call(zoom); + // node.call(zoom); + // link.call(zoom); }, [data]); useEffect(() => { From 73a5802c1108d25206f0009c8168ba8835cd317e Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 19 Jan 2022 14:43:33 -0800 Subject: [PATCH 03/41] Lint. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 375 ++++++++---------- .../src/screens/TopologyView/TopologyView.js | 11 +- 2 files changed, 165 insertions(+), 221 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 52747d0a81..77ca7656f4 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -1,102 +1,102 @@ import React, { useEffect, useCallback } from 'react'; -import { t } from '@lingui/macro'; +// import { t } from '@lingui/macro'; import * as d3 from 'd3'; -// function MeshGraph({ data }) { -function MeshGraph() { - const data = { - nodes: [ - { - hostname: "aapc1.local", - node_state: "healthy", - node_type: "control", - id: 1 - }, - { - hostname: "aapc2.local", - node_type: "control", - node_state: "disabled", - id: 2 - }, - { - hostname: "aapc3.local", - node_type: "control", - node_state: "healthy", - id: 3 - }, - { - hostname: "aape1.local", - node_type: "execution", - node_state: "error", - id: 4 - }, - { - hostname: "aape2.local", - node_type: "execution", - node_state: "error", - id: 5 - }, - { - hostname: "aape3.local", - node_type: "execution", - node_state: "healthy", - id: 6 - }, - { - hostname: "aape4.local", - node_type: "execution", - node_state: "healthy", - id: 7 - }, - { - hostname: "aaph1.local", - node_type: "hop", - node_state: "disabled", - id: 8 - }, - { - hostname: "aaph2.local", - node_type: "hop", - node_state: "healthy", - id: 9 - }, - { - hostname: "aaph3.local", - node_type: "hop", - node_state: "error", - id: 10 - } - ], - links: [ - { source: "aapc1.local", target: "aapc2.local" }, - { source: "aapc1.local", target: "aapc3.local" }, - { source: "aapc1.local", target: "aape1.local" }, - { source: "aapc1.local", target: "aape2.local" }, +function MeshGraph({ data }) { + // function MeshGraph() { + // const data = { + // nodes: [ + // { + // hostname: 'aapc1.local', + // node_state: 'healthy', + // node_type: 'control', + // id: 1, + // }, + // { + // hostname: 'aapc2.local', + // node_type: 'control', + // node_state: 'disabled', + // id: 2, + // }, + // { + // hostname: 'aapc3.local', + // node_type: 'control', + // node_state: 'healthy', + // id: 3, + // }, + // { + // hostname: 'aape1.local', + // node_type: 'execution', + // node_state: 'error', + // id: 4, + // }, + // { + // hostname: 'aape2.local', + // node_type: 'execution', + // node_state: 'error', + // id: 5, + // }, + // { + // hostname: 'aape3.local', + // node_type: 'execution', + // node_state: 'healthy', + // id: 6, + // }, + // { + // hostname: 'aape4.local', + // node_type: 'execution', + // node_state: 'healthy', + // id: 7, + // }, + // { + // hostname: 'aaph1.local', + // node_type: 'hop', + // node_state: 'disabled', + // id: 8, + // }, + // { + // hostname: 'aaph2.local', + // node_type: 'hop', + // node_state: 'healthy', + // id: 9, + // }, + // { + // hostname: 'aaph3.local', + // node_type: 'hop', + // node_state: 'error', + // id: 10, + // }, + // ], + // links: [ + // { source: 'aapc1.local', target: 'aapc2.local' }, + // { source: 'aapc1.local', target: 'aapc3.local' }, + // { source: 'aapc1.local', target: 'aape1.local' }, + // { source: 'aapc1.local', target: 'aape2.local' }, - { source: "aapc2.local", target: "aapc3.local" }, - { source: "aapc2.local", target: "aape1.local" }, - { source: "aapc2.local", target: "aape2.local" }, + // { source: 'aapc2.local', target: 'aapc3.local' }, + // { source: 'aapc2.local', target: 'aape1.local' }, + // { source: 'aapc2.local', target: 'aape2.local' }, - { source: "aapc3.local", target: "aape1.local" }, - { source: "aapc3.local", target: "aape2.local" }, + // { source: 'aapc3.local', target: 'aape1.local' }, + // { source: 'aapc3.local', target: 'aape2.local' }, - { source: "aape3.local", target: "aaph1.local" }, - { source: "aape3.local", target: "aaph2.local" }, + // { source: 'aape3.local', target: 'aaph1.local' }, + // { source: 'aape3.local', target: 'aaph2.local' }, - { source: "aape4.local", target: "aaph3.local" }, + // { source: 'aape4.local', target: 'aaph3.local' }, - { source: "aaph1.local", target: "aapc1.local" }, - { source: "aaph1.local", target: "aapc2.local" }, - { source: "aaph1.local", target: "aapc3.local" }, + // { source: 'aaph1.local', target: 'aapc1.local' }, + // { source: 'aaph1.local', target: 'aapc2.local' }, + // { source: 'aaph1.local', target: 'aapc3.local' }, - { source: "aaph2.local", target: "aapc1.local" }, - { source: "aaph2.local", target: "aapc2.local" }, - { source: "aaph2.local", target: "aapc3.local" }, + // { source: 'aaph2.local', target: 'aapc1.local' }, + // { source: 'aaph2.local', target: 'aapc2.local' }, + // { source: 'aaph2.local', target: 'aapc3.local' }, - { source: "aaph3.local", target: "aaph1.local" }, - { source: "aaph3.local", target: "aaph2.local" } - ] - }; + // { source: 'aaph3.local', target: 'aaph1.local' }, + // { source: 'aaph3.local', target: 'aaph2.local' }, + // ], + // }; const draw = useCallback(() => { const margin = 80; @@ -122,7 +122,7 @@ function MeshGraph() { const zoom = d3 .zoom() .scaleExtent([1, 8]) - .on('zoom', function (event) { + .on('zoom', (event) => { svg.selectAll('.links, .nodes').attr('transform', event.transform); }); @@ -145,18 +145,14 @@ function MeshGraph() { .forceSimulation() .force( 'link', - d3.forceLink().id(function (d) { - return d.hostname; - }) + d3.forceLink().id((d) => 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; - }) + d3.forceCollide((d) => + d.node_type === 'execution' || d.node_type === 'hop' ? 75 : 100 + ) ) .force('center', d3.forceCenter(width / 2, height / 2)); @@ -172,19 +168,8 @@ function MeshGraph() { .style('stroke', '#ccc') .style('stroke-width', '2px') .attr('pointer-events', 'none') - .on('mouseover', function (event, d) { - // tooltip - // .html(`source: ${d.source.hostname}
target: ${d.target.hostname}`) - // .style('visibility', 'visible'); + .on('mouseover', function showPointer() { 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 @@ -194,53 +179,42 @@ function MeshGraph() { .data(graph.nodes) .enter() .append('g') - .on('mouseenter', function (event, d) { + .on('mouseenter', function handleNodeHover(_, d) { d3.select(this).transition().style('cursor', 'pointer'); - highlightSiblings(d) + highlightSiblings(d); tooltip .html( `

Details


name: ${d.hostname}
type: ${d.node_type}
status: ${d.node_state}
Click on a node to view the details` ) - .style('visibility', 'visible') - // .style('visibility', 'visible'); - // d3.select(this).transition().attr('r', 9).style('cursor', 'pointer'); + .style('visibility', 'visible'); }) - .on('mousemove', function () { - // tooltip - // .style('top', event.pageY - 10 + 'px') - // .style('left', event.pageX + 10 + 'px'); - }) - .on('mouseleave', function (event, d) { - deselectSiblings(d) + .on('mouseleave', (_, d) => { + deselectSiblings(d); tooltip.html(``).style('visibility', 'hidden'); - // d3.select(this).attr('r', 6); }); - const healthRings = node + // health rings on nodes + node .append('circle') .attr('r', 8) .attr('class', (d) => d.node_state) - .attr('stroke', d => renderHealthColor(d.node_state)) - .attr('fill', d => renderHealthColor(d.node_state)); + .attr('stroke', (d) => renderHealthColor(d.node_state)) + .attr('fill', (d) => renderHealthColor(d.node_state)); - const nodeRings = node + // inner node ring + node .append('circle') .attr('r', defaultRadius) .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) - .attr('fill', function (d) { - return color(d.node_type); - }) + .attr('fill', (d) => color(d.node_type)) .attr('stroke', 'white'); svg.call(expandGlow); - const legend = svg - .append('text') - .attr('x', 10) - .attr('y', 20) - .text('Legend') + // legend + svg.append('text').attr('x', 10).attr('y', 20).text('Legend'); - svg + svg .append('g') .selectAll('g') .attr('class', 'chart-legend') @@ -248,16 +222,13 @@ function MeshGraph() { .enter() .append('circle') .attr('cx', 10) - .attr('cy', function (d, i) { - return 50 + i * 25; - }) + .attr('cy', (d, i) => 50 + i * 25) .attr('r', defaultRadius) .attr('class', (d) => d.node_type) - .style('fill', function (d) { - return color(d.node_type); - }); + .style('fill', (d) => color(d.node_type)); - const legend_text = svg + // legend text + svg .append('g') .attr('class', 'chart-text') .selectAll('g') @@ -265,9 +236,7 @@ function MeshGraph() { .enter() .append('text') .attr('x', 20) - .attr('y', function (d, i) { - return 50 + i * 25; - }) + .attr('y', (d, i) => 50 + i * 25) .text((d) => `${d.hostname} - ${d.node_type}`) .attr('text-anchor', 'left') .style('alignment-baseline', 'middle'); @@ -292,11 +261,10 @@ function MeshGraph() { .style('color', '#e6e6e') .text(''); - const labels = node + // node labels + node .append('text') - .text(function (d) { - return d.hostname; - }) + .text((d) => d.hostname) .attr('x', 16) .attr('y', 3); @@ -306,29 +274,14 @@ function MeshGraph() { function ticked() { link.attr('d', linkArc); - node.attr('transform', function (d) { - return 'translate(' + d.x + ',' + d.y + ')'; - }); + node.attr('transform', (d) => `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 - ); + const dx = d.target.x - d.source.x; + const dy = d.target.y - d.source.y; + const 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() { @@ -351,58 +304,54 @@ function MeshGraph() { function renderHealthColor(nodeState) { const colorKey = { - 'disabled': '#c6c6c6', - 'healthy': '#50D050', - 'error': '#ff6766' + disabled: '#c6c6c6', + healthy: '#50D050', + error: '#ff6766', }; return colorKey[nodeState]; } - function renderNodeClass(nodeState) { - const colorKey = { - 'disabled': 'node-disabled', - 'healthy': 'node-healthy', - 'error': 'node-error' - }; - return colorKey[nodeState]; + function highlightSiblings(n) { + setTimeout(() => { + svg.selectAll(`id-${n.id}`).attr('r', highlightRadius); + const immediate = graph.links.filter( + (l) => + n.hostname === l.source.hostname || n.hostname === l.target.hostname + ); + immediate.forEach((s) => { + svg + .selectAll(`.link-${s.index}`) + .transition() + .style('stroke', '#6e6e6e'); + svg + .selectAll(`.id-${s.source.id}`) + .transition() + .attr('r', highlightRadius); + svg + .selectAll(`.id-${s.target.id}`) + .transition() + .attr('r', highlightRadius); + }); + }, 0); } - function highlightSiblings(node) { - setTimeout(function() { - svg.selectAll(`id-${node.id}`) - .attr('r', highlightRadius); - const immediate = graph.links.filter(link => node.hostname === link.source.hostname || node.hostname === link.target.hostname); - immediate.forEach(s => { - const links = svg.selectAll(`.link-${s.index}`) + function deselectSiblings(n) { + svg.selectAll(`id-${n.id}`).attr('r', defaultRadius); + const immediate = graph.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'); + svg + .selectAll(`.id-${s.source.id}`) .transition() - .style('stroke', '#6e6e6e') - const sourceNodes = svg.selectAll(`.id-${s.source.id}`) - .transition() - .attr('r', highlightRadius) - const targetNodes = svg.selectAll(`.id-${s.target.id}`) - .transition() - .attr('r', highlightRadius) - }) - - }, 0) - - } - - function deselectSiblings(node) { - svg.selectAll(`id-${node.id}`) .attr('r', defaultRadius); - const immediate = graph.links.filter(link => node.hostname === link.source.hostname || node.hostname === link.target.hostname); - immediate.forEach(s => { - const links = svg.selectAll(`.link-${s.index}`) + svg + .selectAll(`.id-${s.target.id}`) .transition() - .style('stroke', '#ccc') - svg.selectAll(`.id-${s.source.id}`) - .transition() - .attr('r', defaultRadius) - svg.selectAll(`.id-${s.target.id}`) - .transition() - .attr('r', defaultRadius) - }) + .attr('r', defaultRadius); + }); } // const zoom = d3 // .zoom() diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index be43df6ad8..2e62a01e0d 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -2,20 +2,15 @@ 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 { PageSection, Card, CardBody } from '@patternfly/react-core'; import useRequest from 'hooks/useRequest'; import { MeshAPI } from 'api'; +import MeshGraph from './MeshGraph'; function TopologyView() { const { result: { meshData }, - error: fetchInitialError, + // error: fetchInitialError, request: fetchMeshVisualizer, } = useRequest( useCallback(async () => { From 5856f805fc657b1eb91ad805dbbf9a4ac93406d7 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 20 Jan 2022 13:45:03 -0800 Subject: [PATCH 04/41] Add debounce to resize event; link to node details. --- awx/ui/src/api/models/Instances.js | 5 ++++ awx/ui/src/screens/TopologyView/MeshGraph.js | 31 +++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) 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/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 77ca7656f4..3876e1e3ea 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -1,5 +1,8 @@ import React, { useEffect, useCallback } from 'react'; +import debounce from 'util/debounce'; +import { useHistory } from 'react-router-dom'; // import { t } from '@lingui/macro'; +import { InstancesAPI } from 'api'; import * as d3 from 'd3'; function MeshGraph({ data }) { @@ -97,7 +100,7 @@ function MeshGraph({ data }) { // { source: 'aaph3.local', target: 'aaph2.local' }, // ], // }; - + const history = useHistory(); const draw = useCallback(() => { const margin = 80; const getWidth = () => { @@ -191,6 +194,11 @@ function MeshGraph({ data }) { .on('mouseleave', (_, d) => { deselectSiblings(d); tooltip.html(``).style('visibility', 'hidden'); + }) + .on('click', (_, d) => { + if (d.node_type !== 'hop') { + redirectToDetailsPage(d); + } }); // health rings on nodes @@ -353,16 +361,17 @@ function MeshGraph({ data }) { .attr('r', defaultRadius); }); } - // const zoom = d3 - // .zoom() - // .scaleExtent([1, 8]) - // .on('zoom', function (event) { - // svg.selectAll('.links, .nodes').attr('transform', event.transform); - // }); - // svg.call(zoom); - // node.call(zoom); - // link.call(zoom); + async function redirectToDetailsPage({ id: nodeId }) { + const { + data: { results }, + } = await InstancesAPI.readInstanceGroup(nodeId); + const { id: instanceGroupId } = results[0]; + const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; + history.push(constructedURL); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); useEffect(() => { @@ -370,7 +379,7 @@ function MeshGraph({ data }) { draw(); } - window.addEventListener('resize', handleResize); + window.addEventListener('resize', debounce(handleResize, 500)); handleResize(); From 0c8c69f04a4e9eb7cba2d41f07b5f6d78d0eeee4 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 20 Jan 2022 13:53:14 -0800 Subject: [PATCH 05/41] Add RBAC for /topology_view endpoint. --- awx/ui/src/routeConfig.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/src/routeConfig.js b/awx/ui/src/routeConfig.js index f945e9ea79..76bb2e39a5 100644 --- a/awx/ui/src/routeConfig.js +++ b/awx/ui/src/routeConfig.js @@ -185,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; From 07ccce9845c721928ac689cd75d2475f03d75f60 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 20 Jan 2022 13:58:35 -0800 Subject: [PATCH 06/41] Zoom in/out on entire SVG canvas. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 3876e1e3ea..fb4e9d4abb 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -126,7 +126,7 @@ function MeshGraph({ data }) { .zoom() .scaleExtent([1, 8]) .on('zoom', (event) => { - svg.selectAll('.links, .nodes').attr('transform', event.transform); + svg.attr('transform', event.transform); }); /* Add SVG */ From b8674a3f8c9b83b0fdd306567e63beb641ba37ca Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 20 Jan 2022 14:14:28 -0800 Subject: [PATCH 07/41] Use PF colors for nodes. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index fb4e9d4abb..90ba1ea2ed 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -141,7 +141,6 @@ function MeshGraph({ data }) { .attr('transform', `translate(${margin}, ${margin})`) .call(zoom); - const color = d3.scaleOrdinal(d3.schemeCategory10); const graph = data; const simulation = d3 @@ -215,7 +214,7 @@ function MeshGraph({ data }) { .attr('r', defaultRadius) .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) - .attr('fill', (d) => color(d.node_type)) + .attr('fill', (d) => renderNodeColor(d.node_type)) .attr('stroke', 'white'); svg.call(expandGlow); @@ -233,7 +232,7 @@ function MeshGraph({ data }) { .attr('cy', (d, i) => 50 + i * 25) .attr('r', defaultRadius) .attr('class', (d) => d.node_type) - .style('fill', (d) => color(d.node_type)); + .style('fill', (d) => renderNodeColor(d.node_type)); // legend text svg @@ -319,6 +318,17 @@ function MeshGraph({ data }) { return colorKey[nodeState]; } + function renderNodeColor(nodeType) { + const colorKey = { + hop: '#C46100', + execution: '#F0AB00', + hybrid: '#0066CC', + control: '#005F60' + }; + + return colorKey[nodeType]; + } + function highlightSiblings(n) { setTimeout(() => { svg.selectAll(`id-${n.id}`).attr('r', highlightRadius); From 9fc92ccc52c4ee68f9dedd805047e5152ace4859 Mon Sep 17 00:00:00 2001 From: Tiago Date: Mon, 24 Jan 2022 18:17:04 -0300 Subject: [PATCH 08/41] add data-cy attr --- awx/ui/src/screens/TopologyView/MeshGraph.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 90ba1ea2ed..cbe505eecf 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -161,11 +161,13 @@ function MeshGraph({ data }) { const link = svg .append('g') .attr('class', `links`) + .attr('data-cy', 'links') .selectAll('path') .data(graph.links) .enter() .append('path') .attr('class', (d, i) => `link-${i}`) + .attr('data-cy', (d) => `${d.source}-${d.target}`) .style('fill', 'none') .style('stroke', '#ccc') .style('stroke-width', '2px') @@ -177,6 +179,7 @@ function MeshGraph({ data }) { const node = svg .append('g') .attr('class', 'nodes') + .attr('data-cy', 'nodes') .selectAll('g') .data(graph.nodes) .enter() @@ -225,6 +228,7 @@ function MeshGraph({ data }) { .append('g') .selectAll('g') .attr('class', 'chart-legend') + .attr('data-cy', 'chart-legend') .data(graph.nodes) .enter() .append('circle') @@ -238,6 +242,7 @@ function MeshGraph({ data }) { svg .append('g') .attr('class', 'chart-text') + .attr('data-cy', 'chart-text') .selectAll('g') .data(graph.nodes) .enter() @@ -252,6 +257,7 @@ function MeshGraph({ data }) { .select('#chart') .append('div') .attr('class', 'd3-tooltip') + .attr('data-cy', 'd3-tooltip') .style('position', 'absolute') .style('top', '200px') .style('right', '40px') From cd54d560b3d138d3bab821e1dd9936d4496f6636 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 26 Jan 2022 09:19:53 -0800 Subject: [PATCH 09/41] Update layout; fix multiple renders happening on page load. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 252 +++++++++--------- .../src/screens/TopologyView/TopologyView.js | 21 +- 2 files changed, 139 insertions(+), 134 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index cbe505eecf..729ee43776 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -1,107 +1,89 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import debounce from 'util/debounce'; -import { useHistory } from 'react-router-dom'; // import { t } from '@lingui/macro'; -import { InstancesAPI } from 'api'; import * as d3 from 'd3'; -function MeshGraph({ data }) { - // function MeshGraph() { - // const data = { - // nodes: [ - // { - // hostname: 'aapc1.local', - // node_state: 'healthy', - // node_type: 'control', - // id: 1, - // }, - // { - // hostname: 'aapc2.local', - // node_type: 'control', - // node_state: 'disabled', - // id: 2, - // }, - // { - // hostname: 'aapc3.local', - // node_type: 'control', - // node_state: 'healthy', - // id: 3, - // }, - // { - // hostname: 'aape1.local', - // node_type: 'execution', - // node_state: 'error', - // id: 4, - // }, - // { - // hostname: 'aape2.local', - // node_type: 'execution', - // node_state: 'error', - // id: 5, - // }, - // { - // hostname: 'aape3.local', - // node_type: 'execution', - // node_state: 'healthy', - // id: 6, - // }, - // { - // hostname: 'aape4.local', - // node_type: 'execution', - // node_state: 'healthy', - // id: 7, - // }, - // { - // hostname: 'aaph1.local', - // node_type: 'hop', - // node_state: 'disabled', - // id: 8, - // }, - // { - // hostname: 'aaph2.local', - // node_type: 'hop', - // node_state: 'healthy', - // id: 9, - // }, - // { - // hostname: 'aaph3.local', - // node_type: 'hop', - // node_state: 'error', - // id: 10, - // }, - // ], - // links: [ - // { source: 'aapc1.local', target: 'aapc2.local' }, - // { source: 'aapc1.local', target: 'aapc3.local' }, - // { source: 'aapc1.local', target: 'aape1.local' }, - // { source: 'aapc1.local', target: 'aape2.local' }, - - // { source: 'aapc2.local', target: 'aapc3.local' }, - // { source: 'aapc2.local', target: 'aape1.local' }, - // { source: 'aapc2.local', target: 'aape2.local' }, - - // { source: 'aapc3.local', target: 'aape1.local' }, - // { source: 'aapc3.local', target: 'aape2.local' }, - - // { source: 'aape3.local', target: 'aaph1.local' }, - // { source: 'aape3.local', target: 'aaph2.local' }, - - // { source: 'aape4.local', target: 'aaph3.local' }, - - // { source: 'aaph1.local', target: 'aapc1.local' }, - // { source: 'aaph1.local', target: 'aapc2.local' }, - // { source: 'aaph1.local', target: 'aapc3.local' }, - - // { source: 'aaph2.local', target: 'aapc1.local' }, - // { source: 'aaph2.local', target: 'aapc2.local' }, - // { source: 'aaph2.local', target: 'aapc3.local' }, - - // { source: 'aaph3.local', target: 'aaph1.local' }, - // { source: 'aaph3.local', target: 'aaph2.local' }, - // ], - // }; - const history = useHistory(); +// function MeshGraph({ data }) { +function MeshGraph({ redirectToDetailsPage }) { const draw = useCallback(() => { + const data = { + nodes: [ + { + id: 1, + hostname: 'awx_1', + node_type: 'hybrid', + node_state: 'healthy', + }, + { + id: 3, + hostname: 'receptor-1', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 4, + hostname: 'receptor-2', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 2, + hostname: 'receptor-hop', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 5, + hostname: 'receptor-hop-1', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 6, + hostname: 'receptor-hop-2', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 7, + hostname: 'receptor-hop-3', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 8, + hostname: 'receptor-hop-4', + node_type: 'hop', + node_state: 'healthy', + }, + ], + links: [ + { + source: 'receptor-hop', + target: 'awx_1', + }, + { + source: 'receptor-1', + target: 'receptor-hop', + }, + { + source: 'receptor-2', + target: 'receptor-hop', + }, + { + source: 'receptor-hop-3', + target: 'receptor-hop', + }, + // { + // "source": "receptor-2", + // "target": "receptor-hop-1" + // }, + // { + // "source": "receptor-2", + // "target": "receptor-hop-2" + // } + ], + }; const margin = 80; const getWidth = () => { let width; @@ -124,7 +106,7 @@ function MeshGraph({ data }) { const zoom = d3 .zoom() - .scaleExtent([1, 8]) + // .scaleExtent([1, 8]) .on('zoom', (event) => { svg.attr('transform', event.transform); }); @@ -145,27 +127,41 @@ function MeshGraph({ data }) { const simulation = d3 .forceSimulation() + .force('charge', d3.forceManyBody(75).strength(-100)) .force( 'link', d3.forceLink().id((d) => d.hostname) ) - .force('charge', d3.forceManyBody().strength(-350)) - .force( - 'collide', - d3.forceCollide((d) => - d.node_type === 'execution' || d.node_type === 'hop' ? 75 : 100 - ) - ) + .force('collide', d3.forceCollide(80)) + .force('forceX', d3.forceX(0)) + .force('forceY', d3.forceY(0)) .force('center', d3.forceCenter(width / 2, height / 2)); + // const simulation = d3 + // .forceSimulation() + // .force( + // 'link', + // d3.forceLink().id((d) => d.hostname) + // ) + // .force('charge', d3.forceManyBody().strength(-350)) + // .force( + // 'collide', + // d3.forceCollide((d) => + // d.node_type === 'execution' || d.node_type === 'hop' ? 75 : 100 + // ) + // ) + // .force('center', d3.forceCenter(width / 2, height / 2)); + const link = svg .append('g') .attr('class', `links`) .attr('data-cy', 'links') - .selectAll('path') + // .selectAll('path') + .selectAll('line') .data(graph.links) .enter() - .append('path') + .append('line') + // .append('path') .attr('class', (d, i) => `link-${i}`) .attr('data-cy', (d) => `${d.source}-${d.target}`) .style('fill', 'none') @@ -285,17 +281,22 @@ function MeshGraph({ data }) { simulation.force('link').links(graph.links); function ticked() { - link.attr('d', linkArc); + // link.attr('d', linkArc); + link + .attr('x1', (d) => d.source.x) + .attr('y1', (d) => d.source.y) + .attr('x2', (d) => d.target.x) + .attr('y2', (d) => d.target.y); node.attr('transform', (d) => `translate(${d.x},${d.y})`); } - function linkArc(d) { - const dx = d.target.x - d.source.x; - const dy = d.target.y - d.source.y; - const 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 linkArc(d) { + // const dx = d.target.x - d.source.x; + // const dy = d.target.y - d.source.y; + // const 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() { svg @@ -329,7 +330,7 @@ function MeshGraph({ data }) { hop: '#C46100', execution: '#F0AB00', hybrid: '#0066CC', - control: '#005F60' + control: '#005F60', }; return colorKey[nodeType]; @@ -377,18 +378,7 @@ function MeshGraph({ data }) { .attr('r', defaultRadius); }); } - - async function redirectToDetailsPage({ id: nodeId }) { - const { - data: { results }, - } = await InstancesAPI.readInstanceGroup(nodeId); - const { id: instanceGroupId } = results[0]; - const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; - history.push(constructedURL); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { function handleResize() { @@ -396,9 +386,7 @@ function MeshGraph({ data }) { } window.addEventListener('resize', debounce(handleResize, 500)); - - handleResize(); - + draw(); return () => window.removeEventListener('resize', handleResize); }, [draw]); diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index 2e62a01e0d..a3ee8ffab3 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -1,10 +1,11 @@ import React, { useEffect, useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; import { t } from '@lingui/macro'; import ScreenHeader from 'components/ScreenHeader/ScreenHeader'; import { PageSection, Card, CardBody } from '@patternfly/react-core'; import useRequest from 'hooks/useRequest'; -import { MeshAPI } from 'api'; +import { MeshAPI, InstancesAPI } from 'api'; import MeshGraph from './MeshGraph'; function TopologyView() { @@ -21,6 +22,15 @@ function TopologyView() { }, []), { meshData: { nodes: [], links: [] } } ); + async function RedirectToDetailsPage({ id: nodeId }) { + const history = useHistory(); + const { + data: { results }, + } = await InstancesAPI.readInstanceGroup(nodeId); + const { id: instanceGroupId } = results[0]; + const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; + history.push(constructedURL); + } useEffect(() => { fetchMeshVisualizer(); }, [fetchMeshVisualizer]); @@ -30,7 +40,14 @@ function TopologyView() { - {meshData && } + + {meshData && ( + + )} + From 8090cd3032af05c410294db0a23d28bf8b4dcf6f Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 2 Feb 2022 16:00:07 -0800 Subject: [PATCH 10/41] WIP new mesh layout based on QE feedback. --- awx/ui/src/screens/TopologyView/Legend.js | 139 +++++ awx/ui/src/screens/TopologyView/MeshGraph.js | 506 +++++++++--------- awx/ui/src/screens/TopologyView/Tooltip.js | 107 ++++ .../src/screens/TopologyView/TopologyView.js | 23 +- 4 files changed, 500 insertions(+), 275 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/Legend.js create mode 100644 awx/ui/src/screens/TopologyView/Tooltip.js diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js new file mode 100644 index 0000000000..3ecc5492d5 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -0,0 +1,139 @@ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; +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: 190px; +`; +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 ( + + + + Legend + + + Node types + + + + + + + Control node + + + + + + + Execution node + + + + + + + Hybrid node + + + + + + Hop node + + + + Status types + + + + + + + + {nodeDetail.hostname} + + + + Type + + {nodeDetail.node_type} node + + + + Status + + + + + + + )} + + ); +} + +export default Tooltip; diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index a3ee8ffab3..0d596cc5e4 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -1,15 +1,14 @@ import React, { useEffect, useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; - import { t } from '@lingui/macro'; import ScreenHeader from 'components/ScreenHeader/ScreenHeader'; import { PageSection, Card, CardBody } from '@patternfly/react-core'; import useRequest from 'hooks/useRequest'; -import { MeshAPI, InstancesAPI } from 'api'; +import { MeshAPI } from 'api'; import MeshGraph from './MeshGraph'; function TopologyView() { const { + isLoading, result: { meshData }, // error: fetchInitialError, request: fetchMeshVisualizer, @@ -22,15 +21,6 @@ function TopologyView() { }, []), { meshData: { nodes: [], links: [] } } ); - async function RedirectToDetailsPage({ id: nodeId }) { - const history = useHistory(); - const { - data: { results }, - } = await InstancesAPI.readInstanceGroup(nodeId); - const { id: instanceGroupId } = results[0]; - const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; - history.push(constructedURL); - } useEffect(() => { fetchMeshVisualizer(); }, [fetchMeshVisualizer]); @@ -40,14 +30,7 @@ function TopologyView() { - - {meshData && ( - - )} - + {!isLoading && } From 3cfab418d1ee8f666bfd25bc4c39ab42d4029a09 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 2 Feb 2022 20:09:03 -0800 Subject: [PATCH 11/41] Fix zoom on scroll. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 327 +++++++++++++------ 1 file changed, 233 insertions(+), 94 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 399fc3d868..6644fae22a 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -7,96 +7,234 @@ import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; -function MeshGraph({ data }) { - // function MeshGraph() { +// function MeshGraph({ data }) { +function MeshGraph() { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); const history = useHistory(); const draw = () => { - // const data = { - // nodes: [ - // { - // id: 1, - // hostname: 'awx_1', - // node_type: 'hybrid', - // node_state: 'healthy', - // }, - // { - // id: 3, - // hostname: 'receptor-1', - // node_type: 'execution', - // node_state: 'healthy', - // }, - // { - // id: 4, - // hostname: 'receptor-2', - // node_type: 'execution', - // node_state: 'healthy', - // }, - // { - // id: 2, - // hostname: 'receptor-hop', - // node_type: 'hop', - // node_state: 'healthy', - // }, - // { - // id: 5, - // hostname: 'receptor-hop-1', - // node_type: 'hop', - // node_state: 'healthy', - // }, - // { - // id: 6, - // hostname: 'receptor-hop-2', - // node_type: 'hop', - // node_state: 'disabled', - // }, - // { - // id: 7, - // hostname: 'receptor-hop-3', - // node_type: 'hop', - // node_state: 'error', - // }, - // { - // id: 8, - // hostname: 'receptor-hop-4', - // node_type: 'hop', - // node_state: 'healthy', - // }, - // ], - // links: [ - // { - // source: 'receptor-hop', - // target: 'awx_1', - // }, - // { - // source: 'receptor-1', - // target: 'receptor-hop', - // }, - // { - // source: 'receptor-2', - // target: 'receptor-hop', - // }, - // { - // source: 'receptor-hop-3', - // target: 'receptor-hop', - // }, - // // { - // // "source": "receptor-hop", - // // "target": "receptor-hop-1" - // // }, - // // { - // // "source": "receptor-1", - // // "target": "receptor-hop-2" - // // }, - // // { - // // "source": "awx_1", - // // "target": "receptor-hop-4" - // // } - // ], - // }; + const data = { + nodes: [ + { + id: 1, + hostname: 'awx_1', + node_type: 'hybrid', + node_state: 'healthy', + }, + { + id: 3, + hostname: 'receptor-1', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 4, + hostname: 'receptor-2', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 2, + hostname: 'receptor-hop', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 5, + hostname: 'receptor-hop-1', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 6, + hostname: 'receptor-hop-2', + node_type: 'hop', + node_state: 'disabled', + }, + { + id: 7, + hostname: 'receptor-hop-3', + node_type: 'hop', + node_state: 'error', + }, + { + id: 8, + hostname: 'receptor-hop-4', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 9, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 10, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 11, + hostname: 'receptor-hop-6', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 12, + hostname: 'awx_1', + node_type: 'hybrid', + node_state: 'healthy', + }, + { + id: 13, + hostname: 'receptor-1', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 14, + hostname: 'receptor-2', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 1, + hostname: 'receptor-hop', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 5, + hostname: 'receptor-hop-1', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 6, + hostname: 'receptor-hop-2', + node_type: 'hop', + node_state: 'disabled', + }, + { + id: 7, + hostname: 'receptor-hop-3', + node_type: 'hop', + node_state: 'error', + }, + { + id: 8, + hostname: 'receptor-hop-4', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 9, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 10, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 11, + hostname: 'receptor-hop-6', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 14, + hostname: 'receptor-2', + node_type: 'execution', + node_state: 'healthy', + }, + { + id: 1, + hostname: 'receptor-hop', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 5, + hostname: 'receptor-hop-1', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 6, + hostname: 'receptor-hop-2', + node_type: 'hop', + node_state: 'disabled', + }, + { + id: 7, + hostname: 'receptor-hop-3', + node_type: 'hop', + node_state: 'error', + }, + { + id: 8, + hostname: 'receptor-hop-4', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 9, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 10, + hostname: 'receptor-hop-5', + node_type: 'hop', + node_state: 'healthy', + }, + { + id: 11, + hostname: 'receptor-hop-6', + node_type: 'hop', + node_state: 'healthy', + }, + ], + links: [ + { + source: 'receptor-hop', + target: 'awx_1', + }, + { + source: 'receptor-1', + target: 'receptor-hop', + }, + { + source: 'receptor-2', + target: 'receptor-hop', + }, + { + source: 'receptor-hop-3', + target: 'receptor-hop', + }, + // { + // "source": "receptor-hop", + // "target": "receptor-hop-1" + // }, + // { + // "source": "receptor-1", + // "target": "receptor-hop-2" + // }, + // { + // "source": "awx_1", + // "target": "receptor-hop-4" + // } + ], + }; const margin = 15; const defaultRadius = 16; const defaultCollisionFactor = 80; @@ -126,13 +264,7 @@ function MeshGraph({ data }) { return width; }; const width = getWidth(); - - // const zoom = d3 - // .zoom() - // // .scaleExtent([1, 8]) - // .on('zoom', (event) => { - // svg.attr('transform', event.transform); - // }); + const zoom = d3.zoom().scaleExtent([-40, 40]).on('zoom', zoomed); /* Add SVG */ d3.selectAll(`#chart > svg`).remove(); @@ -142,9 +274,10 @@ function MeshGraph({ data }) { .append('svg') .attr('width', `${width + margin}px`) .attr('height', `${height + margin}px`) + .attr('viewBox', [0, 0, width, height]); + const mesh = svg .append('g') .attr('transform', `translate(${margin}, ${margin})`); - // .call(zoom); const graph = data; @@ -163,7 +296,7 @@ function MeshGraph({ data }) { .force('forceY', d3.forceY(defaultForceY)) .force('center', d3.forceCenter(width / 2, height / 2)); - const link = svg + const link = mesh .append('g') .attr('class', `links`) .attr('data-cy', 'links') @@ -181,7 +314,7 @@ function MeshGraph({ data }) { d3.select(this).transition().style('cursor', 'pointer'); }); - const node = svg + const node = mesh .append('g') .attr('class', 'nodes') .attr('data-cy', 'nodes') @@ -263,6 +396,8 @@ function MeshGraph({ data }) { node.attr('transform', (d) => `translate(${d.x},${d.y})`); } + svg.call(zoom); + function renderStateColor(nodeState) { const colorKey = { disabled: '#6A6E73', @@ -340,6 +475,10 @@ function MeshGraph({ data }) { setIsNodeSelected(true); setSelectedNode(n); } + + function zoomed({ transform }) { + mesh.attr('transform', transform); + } }; async function redirectToDetailsPage() { From 7378952a8b73694e2bbe5e99cc0e66dedacc6f2b Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 3 Feb 2022 08:46:04 -0800 Subject: [PATCH 12/41] Add opaque bg to tooltip and legend. --- awx/ui/src/screens/TopologyView/Legend.js | 5 +++-- awx/ui/src/screens/TopologyView/Tooltip.js | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 3ecc5492d5..29e0d0516b 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -23,7 +23,8 @@ const Wrapper = styled.div` top: -20px; left: 0; padding: 10px; - width: 190px; + width: 150px; + background-color: rgba(255, 255, 255, 0.85); `; const Button = styled(PFButton)` width: 20px; @@ -88,7 +89,7 @@ function Legend() { Hybrid node diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js index 2026525260..f294e5a1ed 100644 --- a/awx/ui/src/screens/TopologyView/Tooltip.js +++ b/awx/ui/src/screens/TopologyView/Tooltip.js @@ -24,6 +24,7 @@ const Wrapper = styled.div` right: 0; padding: 10px; width: 20%; + background-color: rgba(255, 255, 255, 0.85); `; const Button = styled(PFButton)` width: 20px; From f3474f081150a3f889e915d3e8eabaee74fc83b6 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 3 Feb 2022 09:32:23 -0800 Subject: [PATCH 13/41] Add legend toggle to header. --- awx/ui/src/screens/TopologyView/Header.js | 53 +++++++++++++++++++ awx/ui/src/screens/TopologyView/MeshGraph.js | 4 +- .../src/screens/TopologyView/TopologyView.js | 18 +++++-- 3 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/Header.js diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js new file mode 100644 index 0000000000..096d79ce22 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { t } from '@lingui/macro'; +import { + PageSection, + PageSectionVariants, + Switch, + Title, + Tooltip, +} from '@patternfly/react-core'; + +const Header = ({ title, handleSwitchToggle, toggleState }) => { + const { light } = PageSectionVariants; + + return ( + +
+
+ + {title} + +
+
+ + handleSwitchToggle(!toggleState)} + /> + +
+
+
+ ); +}; + +Header.propTypes = { + title: PropTypes.string.isRequired, +}; + +export default Header; diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 6644fae22a..d9b4a98a91 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -8,7 +8,7 @@ import Legend from './Legend'; import Tooltip from './Tooltip'; // function MeshGraph({ data }) { -function MeshGraph() { +function MeshGraph({ showLegend }) { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); @@ -517,7 +517,7 @@ function MeshGraph() { return (
- + {showLegend && } - - +
- {!isLoading && } + + {!isLoading && ( + + )} + From afebcc574d2ecdb1e2d9c339e9aa25fe29cdb8ca Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 3 Feb 2022 10:28:27 -0800 Subject: [PATCH 14/41] Add icons to header; randomly generate data. --- awx/ui/src/screens/TopologyView/Header.js | 37 +++ awx/ui/src/screens/TopologyView/MeshGraph.js | 259 +++---------------- 2 files changed, 76 insertions(+), 220 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js index 096d79ce22..ed94f2d295 100644 --- a/awx/ui/src/screens/TopologyView/Header.js +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { t } from '@lingui/macro'; import { + Button, PageSection, PageSectionVariants, Switch, @@ -10,6 +11,12 @@ import { Tooltip, } from '@patternfly/react-core'; +import { + SearchMinusIcon, + SearchPlusIcon, + ExpandArrowsAltIcon, +} from '@patternfly/react-icons'; + const Header = ({ title, handleSwitchToggle, toggleState }) => { const { light } = PageSectionVariants; @@ -32,6 +39,36 @@ const Header = ({ title, handleSwitchToggle, toggleState }) => {
+ + + + + + + + + { + 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 }; + }; + const generateNodes = (n) => { + 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 generateLinks(nodes, getRandomInt(1, n - 1)); + }; + const data = generateNodes(getRandomInt(5, 30)); const draw = () => { - const data = { - nodes: [ - { - id: 1, - hostname: 'awx_1', - node_type: 'hybrid', - node_state: 'healthy', - }, - { - id: 3, - hostname: 'receptor-1', - node_type: 'execution', - node_state: 'healthy', - }, - { - id: 4, - hostname: 'receptor-2', - node_type: 'execution', - node_state: 'healthy', - }, - { - id: 2, - hostname: 'receptor-hop', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 5, - hostname: 'receptor-hop-1', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 6, - hostname: 'receptor-hop-2', - node_type: 'hop', - node_state: 'disabled', - }, - { - id: 7, - hostname: 'receptor-hop-3', - node_type: 'hop', - node_state: 'error', - }, - { - id: 8, - hostname: 'receptor-hop-4', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 9, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 10, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 11, - hostname: 'receptor-hop-6', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 12, - hostname: 'awx_1', - node_type: 'hybrid', - node_state: 'healthy', - }, - { - id: 13, - hostname: 'receptor-1', - node_type: 'execution', - node_state: 'healthy', - }, - { - id: 14, - hostname: 'receptor-2', - node_type: 'execution', - node_state: 'healthy', - }, - { - id: 1, - hostname: 'receptor-hop', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 5, - hostname: 'receptor-hop-1', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 6, - hostname: 'receptor-hop-2', - node_type: 'hop', - node_state: 'disabled', - }, - { - id: 7, - hostname: 'receptor-hop-3', - node_type: 'hop', - node_state: 'error', - }, - { - id: 8, - hostname: 'receptor-hop-4', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 9, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 10, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 11, - hostname: 'receptor-hop-6', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 14, - hostname: 'receptor-2', - node_type: 'execution', - node_state: 'healthy', - }, - { - id: 1, - hostname: 'receptor-hop', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 5, - hostname: 'receptor-hop-1', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 6, - hostname: 'receptor-hop-2', - node_type: 'hop', - node_state: 'disabled', - }, - { - id: 7, - hostname: 'receptor-hop-3', - node_type: 'hop', - node_state: 'error', - }, - { - id: 8, - hostname: 'receptor-hop-4', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 9, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 10, - hostname: 'receptor-hop-5', - node_type: 'hop', - node_state: 'healthy', - }, - { - id: 11, - hostname: 'receptor-hop-6', - node_type: 'hop', - node_state: 'healthy', - }, - ], - links: [ - { - source: 'receptor-hop', - target: 'awx_1', - }, - { - source: 'receptor-1', - target: 'receptor-hop', - }, - { - source: 'receptor-2', - target: 'receptor-hop', - }, - { - source: 'receptor-hop-3', - target: 'receptor-hop', - }, - // { - // "source": "receptor-hop", - // "target": "receptor-hop-1" - // }, - // { - // "source": "receptor-1", - // "target": "receptor-hop-2" - // }, - // { - // "source": "awx_1", - // "target": "receptor-hop-4" - // } - ], - }; const margin = 15; const defaultRadius = 16; const defaultCollisionFactor = 80; From cf459dc4e8a73f9317d493a0cd3e6ef053bffeb0 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Fri, 4 Feb 2022 07:58:52 -0800 Subject: [PATCH 15/41] Remove placeholder label text. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 15d4f70b7f..f971807121 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -19,8 +19,8 @@ function MeshGraph({ showLegend }) { max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } - let nodes = []; - let links = []; + const nodes = []; + const links = []; const generateLinks = (n, r) => { for (let i = 0; i < r; i++) { const link = { @@ -175,6 +175,7 @@ function MeshGraph({ showLegend }) { hostNames .append('text') .text((d) => renderLabelText(d.node_state, d.hostname)) + .attr('class', 'placeholder') .attr('fill', defaultNodeLabelColor) .attr('text-anchor', 'middle') .attr('y', 40) @@ -192,7 +193,7 @@ function MeshGraph({ showLegend }) { .attr('ry', 8) .style('fill', (d) => renderStateColor(d.node_state)); }); - + svg.selectAll('text.placeholder').remove(); hostNames .append('text') .text((d) => renderLabelText(d.node_state, d.hostname)) From 04a550cc67de82b844eb7d01af8defbf096a64b1 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Sun, 6 Feb 2022 11:33:04 -0800 Subject: [PATCH 16/41] Hook up zoom in, zoom out buttons. --- awx/ui/src/screens/TopologyView/Header.js | 11 +++++++++-- awx/ui/src/screens/TopologyView/MeshGraph.js | 9 +++------ awx/ui/src/screens/TopologyView/TopologyView.js | 16 +++++++++++++++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js index ed94f2d295..4dacc7b598 100644 --- a/awx/ui/src/screens/TopologyView/Header.js +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -17,9 +17,14 @@ import { ExpandArrowsAltIcon, } from '@patternfly/react-icons'; -const Header = ({ title, handleSwitchToggle, toggleState }) => { +const Header = ({ + title, + handleSwitchToggle, + toggleState, + zoomIn, + zoomOut, +}) => { const { light } = PageSectionVariants; - return (
{ aria-label={t`Zoom in`} variant="plain" icon={} + onClick={zoomIn} > @@ -55,6 +61,7 @@ const Header = ({ title, handleSwitchToggle, toggleState }) => { aria-label={t`Zoom out`} variant="plain" icon={} + onClick={zoomOut} > diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index f971807121..ab808f2a50 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -8,7 +8,7 @@ import Legend from './Legend'; import Tooltip from './Tooltip'; // function MeshGraph({ data }) { -function MeshGraph({ showLegend }) { +function MeshGraph({ showLegend, zoom }) { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); @@ -83,7 +83,6 @@ function MeshGraph({ showLegend }) { return width; }; const width = getWidth(); - const zoom = d3.zoom().scaleExtent([-40, 40]).on('zoom', zoomed); /* Add SVG */ d3.selectAll(`#chart > svg`).remove(); @@ -91,11 +90,13 @@ function MeshGraph({ showLegend }) { const svg = d3 .select('#chart') .append('svg') + .attr('class', 'mesh-svg') .attr('width', `${width + margin}px`) .attr('height', `${height + margin}px`) .attr('viewBox', [0, 0, width, height]); const mesh = svg .append('g') + .attr('class', 'mesh') .attr('transform', `translate(${margin}, ${margin})`); const graph = data; @@ -295,10 +296,6 @@ function MeshGraph({ showLegend }) { setIsNodeSelected(true); setSelectedNode(n); } - - function zoomed({ transform }) { - mesh.attr('transform', transform); - } }; async function redirectToDetailsPage() { diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index 1fde272709..ac36360669 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -1,4 +1,5 @@ import React, { useEffect, useCallback, useState } from 'react'; +import * as d3 from 'd3'; import { t } from '@lingui/macro'; import { PageSection, Card, CardBody } from '@patternfly/react-core'; import useRequest from 'hooks/useRequest'; @@ -25,18 +26,31 @@ function TopologyView() { useEffect(() => { fetchMeshVisualizer(); }, [fetchMeshVisualizer]); + + const zoom = d3.zoom().on('zoom', ({ transform }) => { + d3.select('.mesh').attr('transform', transform); + }); + const zoomIn = () => { + d3.select('.mesh-svg').transition().call(zoom.scaleBy, 2); + }; + const zoomOut = () => { + d3.select('.mesh-svg').transition().call(zoom.scaleBy, 0.5); + }; + return ( <>
{!isLoading && ( - + )} From 391907c41ea38080eed4e6f3a3efcac70bb39807 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Sun, 6 Feb 2022 11:41:59 -0800 Subject: [PATCH 17/41] Add reset zoom button. --- awx/ui/src/screens/TopologyView/Header.js | 13 +++++++++++++ awx/ui/src/screens/TopologyView/TopologyView.js | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js index 4dacc7b598..e1b8f6648d 100644 --- a/awx/ui/src/screens/TopologyView/Header.js +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -15,6 +15,7 @@ import { SearchMinusIcon, SearchPlusIcon, ExpandArrowsAltIcon, + ExpandIcon, } from '@patternfly/react-icons'; const Header = ({ @@ -23,6 +24,7 @@ const Header = ({ toggleState, zoomIn, zoomOut, + resetZoom, }) => { const { light } = PageSectionVariants; return ( @@ -76,6 +78,17 @@ const Header = ({ + + + { d3.select('.mesh-svg').transition().call(zoom.scaleBy, 0.5); }; + const resetZoom = () => { + const margin = 15; + const width = parseInt(d3.select(`#chart`).style('width'), 10) - margin; + d3.select('.mesh-svg').transition().duration(750).call( + zoom.transform, + d3.zoomIdentity, + d3.zoomTransform(d3.select('.mesh-svg').node()).invert([width / 2, 600 / 2]) + ); + } return ( <> @@ -45,6 +54,7 @@ function TopologyView() { toggleState={showLegend} zoomIn={zoomIn} zoomOut={zoomOut} + resetZoom={resetZoom} /> From b859c3360de09ff6ca9355c62c7ab9bebc453902 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Mon, 7 Feb 2022 09:12:49 -0800 Subject: [PATCH 18/41] Add zoom to fit. --- awx/ui/src/screens/TopologyView/Header.js | 2 + awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- .../src/screens/TopologyView/TopologyView.js | 40 ++++++++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js index e1b8f6648d..4929c8ceb1 100644 --- a/awx/ui/src/screens/TopologyView/Header.js +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -25,6 +25,7 @@ const Header = ({ zoomIn, zoomOut, resetZoom, + zoomFit, }) => { const { light } = PageSectionVariants; return ( @@ -74,6 +75,7 @@ const Header = ({ aria-label={t`Fit to screen`} variant="plain" icon={} + onClick={zoomFit} > diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index ab808f2a50..a94c805340 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -52,7 +52,7 @@ function MeshGraph({ showLegend, zoom }) { } return generateLinks(nodes, getRandomInt(1, n - 1)); }; - const data = generateNodes(getRandomInt(5, 30)); + const data = generateNodes(getRandomInt(250, 250)); const draw = () => { const margin = 15; const defaultRadius = 16; diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index bad237100b..21c52537ba 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -38,13 +38,40 @@ function TopologyView() { }; const resetZoom = () => { const margin = 15; + const height = 600; const width = parseInt(d3.select(`#chart`).style('width'), 10) - margin; - d3.select('.mesh-svg').transition().duration(750).call( - zoom.transform, - d3.zoomIdentity, - d3.zoomTransform(d3.select('.mesh-svg').node()).invert([width / 2, 600 / 2]) - ); - } + d3.select('.mesh-svg') + .transition() + .duration(750) + .call( + zoom.transform, + d3.zoomIdentity, + d3 + .zoomTransform(d3.select('.mesh-svg').node()) + .invert([width / 2, height / 2]) + ); + }; + + const zoomFit = () => { + const bounds = d3.select('.mesh').node().getBBox(); + const parent = d3.select('.mesh').node().parentElement; + const fullWidth = parent.clientWidth; + const fullHeight = parent.clientHeight; + 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('.mesh-svg') + .transition() + .duration(750) + .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); + }; return ( <> @@ -54,6 +81,7 @@ function TopologyView() { toggleState={showLegend} zoomIn={zoomIn} zoomOut={zoomOut} + zoomFit={zoomFit} resetZoom={resetZoom} /> From a6bc0d42229d858f563b40014fb1f54eb36fcba7 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 8 Feb 2022 20:13:46 -0800 Subject: [PATCH 19/41] Add loading screen. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index a94c805340..71cc0fe38e 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -1,12 +1,20 @@ import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; +import styled from 'styled-components'; import { InstancesAPI } from 'api'; import debounce from 'util/debounce'; // import { t } from '@lingui/macro'; import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; +import ContentLoading from '../../components/ContentLoading'; +const Loader = styled(ContentLoading)` + height: 100%; + position: absolute; + width: 100%; + background: white; +`; // function MeshGraph({ data }) { function MeshGraph({ showLegend, zoom }) { const [isNodeSelected, setIsNodeSelected] = useState(false); @@ -86,7 +94,6 @@ function MeshGraph({ showLegend, zoom }) { /* Add SVG */ d3.selectAll(`#chart > svg`).remove(); - const svg = d3 .select('#chart') .append('svg') @@ -208,6 +215,8 @@ function MeshGraph({ showLegend, zoom }) { function ticked() { // link.attr('d', linkArc); + d3.select('.simulation-loader').style('visibility', 'visible'); + link .attr('x1', (d) => d.source.x) .attr('y1', (d) => d.source.y) @@ -215,6 +224,9 @@ function MeshGraph({ showLegend, zoom }) { .attr('y2', (d) => d.target.y); node.attr('transform', (d) => `translate(${d.x},${d.y})`); + if (simulation.alpha() < simulation.alphaMin()) { + d3.select('.simulation-loader').style('visibility', 'hidden'); + } } svg.call(zoom); @@ -325,6 +337,7 @@ function MeshGraph({ showLegend, zoom }) { } useEffect(() => { function handleResize() { + d3.select('.simulation-loader').style('visibility', 'visible'); draw(); } window.addEventListener('resize', debounce(handleResize, 500)); @@ -341,6 +354,7 @@ function MeshGraph({ showLegend, zoom }) { nodeDetail={nodeDetail} redirectToDetailsPage={redirectToDetailsPage} /> +
); } From 4235bf67f8c2c5fc80c74d8a58d19e130ca783e7 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 8 Feb 2022 20:24:42 -0800 Subject: [PATCH 20/41] Truncate long host names in graph, show full name in tooltip. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 4 +++- awx/ui/src/util/strings.js | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 71cc0fe38e..546b3b44bc 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -8,6 +8,7 @@ import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; import ContentLoading from '../../components/ContentLoading'; +import { truncateString } from '../../util/strings'; const Loader = styled(ContentLoading)` height: 100%; @@ -75,6 +76,7 @@ function MeshGraph({ showLegend, zoom }) { const defaultNodeHighlightColor = '#16407C'; const defaultNodeLabelColor = 'white'; const defaultFontSize = '12px'; + const labelMaxLen = 15; const getWidth = () => { let width; // This is in an a try/catch due to an error from jest. @@ -245,7 +247,7 @@ function MeshGraph({ showLegend, zoom }) { healthy: '\u2713', error: '\u0021', }; - return `${stateKey[nodeState]} ${name}`; + return `${stateKey[nodeState]} ${truncateString(name, labelMaxLen)}`; } function renderNodeType(nodeType) { 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)}...`; +}; From 9854f8a6abd09ef50ad02269e9af004d41a6ad4b Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 9 Feb 2022 09:19:25 -0800 Subject: [PATCH 21/41] Use alpha decay percentage instead of absolute value for loading screen. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 546b3b44bc..a30d5785d2 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -226,9 +226,7 @@ function MeshGraph({ showLegend, zoom }) { .attr('y2', (d) => d.target.y); node.attr('transform', (d) => `translate(${d.x},${d.y})`); - if (simulation.alpha() < simulation.alphaMin()) { - d3.select('.simulation-loader').style('visibility', 'hidden'); - } + calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 35); } svg.call(zoom); @@ -310,9 +308,17 @@ function MeshGraph({ showLegend, zoom }) { setIsNodeSelected(true); setSelectedNode(n); } + + function calculateAlphaDecay(a, aMin, x) { + const decayPercentage = Math.min((aMin / a) * 100); + if (decayPercentage >= x) { + d3.select('.simulation-loader').style('visibility', 'hidden'); + } + } }; async function redirectToDetailsPage() { + // TODO: redirect to top-level instances details page const { id: nodeId } = selectedNode; const { data: { results }, From d785f30c5fdecb201233a8aa4dfcd5a79f235df5 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 9 Feb 2022 09:19:50 -0800 Subject: [PATCH 22/41] Fix JSX errors. --- awx/ui/src/screens/TopologyView/Legend.js | 4 ++-- awx/ui/src/screens/TopologyView/Tooltip.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 29e0d0516b..2d729e5737 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -56,11 +56,11 @@ const Text = styled(PFText)` function Legend() { return ( - + Legend diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js index f294e5a1ed..142f345448 100644 --- a/awx/ui/src/screens/TopologyView/Tooltip.js +++ b/awx/ui/src/screens/TopologyView/Tooltip.js @@ -50,12 +50,12 @@ function Tooltip({ redirectToDetailsPage, }) { return ( - + {isNodeSelected === false ? ( Details From 272e0126269b0ac07db6f3a5a8282e71e7383831 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 9 Feb 2022 11:18:19 -0800 Subject: [PATCH 23/41] Add new loading screen placeholder. --- .../screens/TopologyView/ContentLoading.js | 41 +++++++++++++++++++ awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 awx/ui/src/screens/TopologyView/ContentLoading.js diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js new file mode 100644 index 0000000000..c92555e47d --- /dev/null +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import styled from 'styled-components'; +import { + EmptyState as PFEmptyState, + EmptyStateIcon, + Text, + TextContent, + TextVariants, + Spinner, +} 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 }) => ( + + + + + Please wait until the topology view is populated... + + + + +); + +export { ContentLoading as _ContentLoading }; +export default ContentLoading; diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index a30d5785d2..67ddf3680a 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -7,7 +7,7 @@ import debounce from 'util/debounce'; import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; -import ContentLoading from '../../components/ContentLoading'; +import ContentLoading from './ContentLoading'; import { truncateString } from '../../util/strings'; const Loader = styled(ContentLoading)` From 69a42b1a89c21c337eb938cfe33309d2422add81 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 10 Feb 2022 20:47:19 -0800 Subject: [PATCH 24/41] Some lint fixes; fix routesConfig unit test. --- awx/ui/src/routeConfig.test.js | 5 +++++ awx/ui/src/screens/TopologyView/ContentLoading.js | 3 ++- awx/ui/src/screens/TopologyView/MeshGraph.js | 4 ++-- awx/ui/src/screens/TopologyView/Tooltip.js | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js index da0dc7e536..35e0a5eae3 100644 --- a/awx/ui/src/routeConfig.test.js +++ b/awx/ui/src/routeConfig.test.js @@ -43,6 +43,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', '/settings', ]); }); @@ -71,6 +72,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', '/settings', ]); }); @@ -98,6 +100,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', ]); }); @@ -233,6 +236,7 @@ describe('getRouteConfig', () => { '/instances', '/applications', '/execution_environments', + '/topology_view', ]); }); @@ -263,6 +267,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 index c92555e47d..b137299c5d 100644 --- a/awx/ui/src/screens/TopologyView/ContentLoading.js +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -1,4 +1,5 @@ import React from 'react'; +import { t } from '@lingui/macro'; import styled from 'styled-components'; import { @@ -30,7 +31,7 @@ const ContentLoading = ({ className }) => ( component={TextVariants.small} style={{ fontWeight: 'bold', color: 'black' }} > - Please wait until the topology view is populated... + {t`Please wait until the topology view is populated...`} diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 67ddf3680a..4dc7488063 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -111,14 +111,14 @@ function MeshGraph({ showLegend, zoom }) { const graph = data; const simulation = d3 - .forceSimulation() + .forceSimulation(graph.nodes) .force( 'charge', d3.forceManyBody(defaultForceBody).strength(defaultForceStrength) ) .force( 'link', - d3.forceLink().id((d) => d.hostname) + d3.forceLink(graph.links).id((d) => d.hostname) ) .force('collide', d3.forceCollide(defaultCollisionFactor)) .force('forceX', d3.forceX(defaultForceX)) diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js index 142f345448..f82a742158 100644 --- a/awx/ui/src/screens/TopologyView/Tooltip.js +++ b/awx/ui/src/screens/TopologyView/Tooltip.js @@ -69,7 +69,7 @@ function Tooltip({ Details From b1570302bc85f85afd3e3d9349bcceaf5e17024d Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Fri, 11 Feb 2022 14:02:37 -0800 Subject: [PATCH 25/41] Refactor: move constants and helper functions into their own files. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 196 ++++++------------ awx/ui/src/screens/TopologyView/Tooltip.js | 4 +- awx/ui/src/screens/TopologyView/constants.js | 38 ++++ .../src/screens/TopologyView/utils/helpers.js | 86 ++++++++ 4 files changed, 185 insertions(+), 139 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/constants.js create mode 100644 awx/ui/src/screens/TopologyView/utils/helpers.js diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 4dc7488063..3e949e6d05 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -1,14 +1,32 @@ import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; -import { InstancesAPI } from 'api'; import debounce from 'util/debounce'; // import { t } from '@lingui/macro'; import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; import ContentLoading from './ContentLoading'; -import { truncateString } from '../../util/strings'; +import { + renderStateColor, + renderLabelText, + renderNodeType, + renderNodeIcon, + redirectToDetailsPage, + // generateRandomNodes, + // getRandomInt, +} from './utils/helpers'; +import { + MESH_FORCE_LAYOUT, + DEFAULT_RADIUS, + DEFAULT_NODE_COLOR, + DEFAULT_NODE_HIGHLIGHT_COLOR, + DEFAULT_NODE_LABEL_TEXT_COLOR, + DEFAULT_FONT_SIZE, + MARGIN, + HEIGHT, + FALLBACK_WIDTH, +} from './constants'; const Loader = styled(ContentLoading)` height: 100%; @@ -16,67 +34,15 @@ const Loader = styled(ContentLoading)` width: 100%; background: white; `; -// function MeshGraph({ data }) { -function MeshGraph({ showLegend, zoom }) { +function MeshGraph({ data, showLegend, zoom }) { + // function MeshGraph({ showLegend, zoom }) { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); const history = useHistory(); - function getRandomInt(min, max) { - min = Math.ceil(min); - max = Math.floor(max); - return Math.floor(Math.random() * (max - min + 1)) + min; - } - const nodes = []; - const links = []; - const generateLinks = (n, r) => { - 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 }; - }; - const generateNodes = (n) => { - 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 generateLinks(nodes, getRandomInt(1, n - 1)); - }; - const data = generateNodes(getRandomInt(250, 250)); + // const data = generateRandomNodes(getRandomInt(4, 50)); const draw = () => { - const margin = 15; - const defaultRadius = 16; - const defaultCollisionFactor = 80; - const defaultForceStrength = -100; - const defaultForceBody = 75; - const defaultForceX = 0; - const defaultForceY = 0; - const height = 600; - const fallbackWidth = 700; - const defaultNodeColor = '#0066CC'; - const defaultNodeHighlightColor = '#16407C'; - const defaultNodeLabelColor = 'white'; - const defaultFontSize = '12px'; - const labelMaxLen = 15; const getWidth = () => { let width; // This is in an a try/catch due to an error from jest. @@ -84,10 +50,10 @@ function MeshGraph({ showLegend, zoom }) { // style function, it says it is null in the test try { width = - parseInt(d3.select(`#chart`).style('width'), 10) - margin || - fallbackWidth; + parseInt(d3.select(`#chart`).style('width'), 10) - MARGIN || + FALLBACK_WIDTH; } catch (error) { - width = fallbackWidth; + width = FALLBACK_WIDTH; } return width; @@ -100,13 +66,13 @@ function MeshGraph({ showLegend, zoom }) { .select('#chart') .append('svg') .attr('class', 'mesh-svg') - .attr('width', `${width + margin}px`) - .attr('height', `${height + margin}px`) - .attr('viewBox', [0, 0, width, height]); + .attr('width', `${width + MARGIN}px`) + .attr('height', `${HEIGHT + MARGIN}px`) + .attr('viewBox', [0, 0, width, HEIGHT]); const mesh = svg .append('g') .attr('class', 'mesh') - .attr('transform', `translate(${margin}, ${margin})`); + .attr('transform', `translate(${MARGIN}, ${MARGIN})`); const graph = data; @@ -114,16 +80,21 @@ function MeshGraph({ showLegend, zoom }) { .forceSimulation(graph.nodes) .force( 'charge', - d3.forceManyBody(defaultForceBody).strength(defaultForceStrength) + d3 + .forceManyBody(MESH_FORCE_LAYOUT.defaultForceBody) + .strength(MESH_FORCE_LAYOUT.defaultForceStrength) ) .force( 'link', d3.forceLink(graph.links).id((d) => d.hostname) ) - .force('collide', d3.forceCollide(defaultCollisionFactor)) - .force('forceX', d3.forceX(defaultForceX)) - .force('forceY', d3.forceY(defaultForceY)) - .force('center', d3.forceCenter(width / 2, height / 2)); + .force( + 'collide', + d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor) + ) + .force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX)) + .force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY)) + .force('center', d3.forceCenter(width / 2, HEIGHT / 2)); const link = mesh .append('g') @@ -133,7 +104,7 @@ function MeshGraph({ showLegend, zoom }) { .data(graph.links) .enter() .append('line') - .attr('class', (d, i) => `link-${i}`) + .attr('class', (_, i) => `link-${i}`) .attr('data-cy', (d) => `${d.source}-${d.target}`) .style('fill', 'none') .style('stroke', '#ccc') @@ -166,11 +137,11 @@ function MeshGraph({ showLegend, zoom }) { // node circles node .append('circle') - .attr('r', defaultRadius) + .attr('r', DEFAULT_RADIUS) .attr('class', (d) => d.node_type) .attr('class', (d) => `id-${d.id}`) - .attr('fill', defaultNodeColor) - .attr('stroke', defaultNodeLabelColor); + .attr('fill', DEFAULT_NODE_COLOR) + .attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR); // node type labels node @@ -178,7 +149,7 @@ function MeshGraph({ showLegend, zoom }) { .text((d) => renderNodeType(d.node_type)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'central') - .attr('fill', defaultNodeLabelColor); + .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR); // node hostname labels const hostNames = node.append('g'); @@ -186,7 +157,7 @@ function MeshGraph({ showLegend, zoom }) { .append('text') .text((d) => renderLabelText(d.node_state, d.hostname)) .attr('class', 'placeholder') - .attr('fill', defaultNodeLabelColor) + .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) .attr('text-anchor', 'middle') .attr('y', 40) .each(function calculateLabelWidth() { @@ -207,8 +178,8 @@ function MeshGraph({ showLegend, zoom }) { hostNames .append('text') .text((d) => renderLabelText(d.node_state, d.hostname)) - .attr('font-size', defaultFontSize) - .attr('fill', defaultNodeLabelColor) + .attr('font-size', DEFAULT_FONT_SIZE) + .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) .attr('text-anchor', 'middle') .attr('y', 38); @@ -216,7 +187,6 @@ function MeshGraph({ showLegend, zoom }) { simulation.force('link').links(graph.links); function ticked() { - // link.attr('d', linkArc); d3.select('.simulation-loader').style('visibility', 'visible'); link @@ -226,42 +196,16 @@ function MeshGraph({ showLegend, zoom }) { .attr('y2', (d) => d.target.y); node.attr('transform', (d) => `translate(${d.x},${d.y})`); - calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 35); + calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 20); } svg.call(zoom); - function renderStateColor(nodeState) { - const colorKey = { - disabled: '#6A6E73', - healthy: '#3E8635', - error: '#C9190B', - }; - return colorKey[nodeState]; - } - function renderLabelText(nodeState, name) { - const stateKey = { - disabled: '\u25EF', - healthy: '\u2713', - error: '\u0021', - }; - return `${stateKey[nodeState]} ${truncateString(name, labelMaxLen)}`; - } - - function renderNodeType(nodeType) { - const typeKey = { - hop: 'h', - execution: 'Ex', - hybrid: 'Hy', - control: 'C', - }; - - return typeKey[nodeType]; - } - function highlightSiblings(n) { setTimeout(() => { - svg.select(`circle.id-${n.id}`).attr('fill', defaultNodeHighlightColor); + svg + .select(`circle.id-${n.id}`) + .attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR); const immediate = graph.links.filter( (l) => n.hostname === l.source.hostname || n.hostname === l.target.hostname @@ -277,7 +221,7 @@ function MeshGraph({ showLegend, zoom }) { } function deselectSiblings(n) { - svg.select(`circle.id-${n.id}`).attr('fill', defaultNodeColor); + svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR); const immediate = graph.links.filter( (l) => n.hostname === l.source.hostname || n.hostname === l.target.hostname @@ -317,35 +261,11 @@ function MeshGraph({ showLegend, zoom }) { } }; - async function redirectToDetailsPage() { - // TODO: redirect to top-level instances details page - const { id: nodeId } = selectedNode; - const { - data: { results }, - } = await InstancesAPI.readInstanceGroup(nodeId); - const { id: instanceGroupId } = results[0]; - const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; - history.push(constructedURL); - } - - function renderNodeIcon() { - if (selectedNode) { - const { node_type: nodeType } = selectedNode; - const typeKey = { - hop: 'h', - execution: 'Ex', - hybrid: 'Hy', - control: 'C', - }; - - return typeKey[nodeType]; - } - - return false; - } useEffect(() => { function handleResize() { d3.select('.simulation-loader').style('visibility', 'visible'); + setSelectedNode(null); + setIsNodeSelected(false); draw(); } window.addEventListener('resize', debounce(handleResize, 500)); @@ -358,9 +278,11 @@ function MeshGraph({ showLegend, zoom }) { {showLegend && } + redirectToDetailsPage(selectedNode, history) + } />
diff --git a/awx/ui/src/screens/TopologyView/Tooltip.js b/awx/ui/src/screens/TopologyView/Tooltip.js index f82a742158..34dae9c8a9 100644 --- a/awx/ui/src/screens/TopologyView/Tooltip.js +++ b/awx/ui/src/screens/TopologyView/Tooltip.js @@ -69,7 +69,7 @@ function Tooltip({ Details @@ -79,7 +79,7 @@ function Tooltip({ diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js new file mode 100644 index 0000000000..642e2cadc7 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -0,0 +1,38 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export const MESH_FORCE_LAYOUT = { + defaultCollisionFactor: 80, + defaultForceStrength: -100, + defaultForceBody: 75, + defaultForceX: 0, + defaultForceY: 0, +}; + +export const DEFAULT_RADIUS = 16; +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 HEIGHT = 600; +export const FALLBACK_WIDTH = 700; + +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/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js new file mode 100644 index 0000000000..cbb6158c5b --- /dev/null +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -0,0 +1,86 @@ +import { InstancesAPI } from 'api'; +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 async function redirectToDetailsPage(selectedNode, history) { + // TODO: redirect to top-level instances details page + const { id: nodeId } = selectedNode; + const { + data: { results }, + } = await InstancesAPI.readInstanceGroup(nodeId); + const { id: instanceGroupId } = results[0]; + const constructedURL = `/instance_groups/${instanceGroupId}/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)); +}; From c102bf05af95b88b05fb56c800ba9e0684d83a1b Mon Sep 17 00:00:00 2001 From: kialam Date: Wed, 16 Feb 2022 14:55:37 -0800 Subject: [PATCH 26/41] Update awx/ui/src/screens/TopologyView/MeshGraph.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tiago Góes --- awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 3e949e6d05..d3114df877 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -105,7 +105,7 @@ function MeshGraph({ data, showLegend, zoom }) { .enter() .append('line') .attr('class', (_, i) => `link-${i}`) - .attr('data-cy', (d) => `${d.source}-${d.target}`) + .attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`) .style('fill', 'none') .style('stroke', '#ccc') .style('stroke-width', '2px') From af1845369127c523d1cc7552cfaba64145c67c52 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 17 Feb 2022 10:18:38 -0800 Subject: [PATCH 27/41] Use 100% height. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 38 +++++-------------- .../src/screens/TopologyView/TopologyView.js | 8 ++-- awx/ui/src/screens/TopologyView/constants.js | 2 +- .../src/screens/TopologyView/utils/helpers.js | 9 +++++ 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index d3114df877..df93bdeddb 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -13,6 +13,8 @@ import { renderNodeType, renderNodeIcon, redirectToDetailsPage, + getHeight, + getWidth, // generateRandomNodes, // getRandomInt, } from './utils/helpers'; @@ -23,9 +25,7 @@ import { DEFAULT_NODE_HIGHLIGHT_COLOR, DEFAULT_NODE_LABEL_TEXT_COLOR, DEFAULT_FONT_SIZE, - MARGIN, - HEIGHT, - FALLBACK_WIDTH, + SELECTOR, } from './constants'; const Loader = styled(ContentLoading)` @@ -43,22 +43,8 @@ function MeshGraph({ data, showLegend, zoom }) { // const data = generateRandomNodes(getRandomInt(4, 50)); const draw = () => { - 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 || - FALLBACK_WIDTH; - } catch (error) { - width = FALLBACK_WIDTH; - } - - return width; - }; - const width = getWidth(); + const width = getWidth(SELECTOR); + const height = getHeight(SELECTOR); /* Add SVG */ d3.selectAll(`#chart > svg`).remove(); @@ -66,13 +52,9 @@ function MeshGraph({ data, showLegend, zoom }) { .select('#chart') .append('svg') .attr('class', 'mesh-svg') - .attr('width', `${width + MARGIN}px`) - .attr('height', `${HEIGHT + MARGIN}px`) - .attr('viewBox', [0, 0, width, HEIGHT]); - const mesh = svg - .append('g') - .attr('class', 'mesh') - .attr('transform', `translate(${MARGIN}, ${MARGIN})`); + .attr('width', `${width}px`) + .attr('height', `100%`); + const mesh = svg.append('g').attr('class', 'mesh'); const graph = data; @@ -94,7 +76,7 @@ function MeshGraph({ data, showLegend, zoom }) { ) .force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX)) .force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY)) - .force('center', d3.forceCenter(width / 2, HEIGHT / 2)); + .force('center', d3.forceCenter(width / 2, height / 2)); const link = mesh .append('g') @@ -274,7 +256,7 @@ function MeshGraph({ data, showLegend, zoom }) { }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( -
+
{showLegend && } { - const margin = 15; - const height = 600; - const width = parseInt(d3.select(`#chart`).style('width'), 10) - margin; + const parent = d3.select('.mesh').node().parentElement; + const width = parent.clientWidth; + const height = parent.clientHeight; d3.select('.mesh-svg') .transition() .duration(750) @@ -85,7 +85,7 @@ function TopologyView() { resetZoom={resetZoom} /> - + {!isLoading && ( diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index 642e2cadc7..f4538dea63 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -1,4 +1,5 @@ /* eslint-disable-next-line import/prefer-default-export */ +export const SELECTOR = '#chart'; export const MESH_FORCE_LAYOUT = { defaultCollisionFactor: 80, defaultForceStrength: -100, @@ -15,7 +16,6 @@ export const DEFAULT_FONT_SIZE = '12px'; export const LABEL_TEXT_MAX_LENGTH = 15; export const MARGIN = 15; -export const HEIGHT = 600; export const FALLBACK_WIDTH = 700; export const NODE_STATE_COLOR_KEY = { diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index cbb6158c5b..866b0fc029 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -1,3 +1,4 @@ +import * as d3 from 'd3'; import { InstancesAPI } from 'api'; import { truncateString } from '../../../util/strings'; @@ -84,3 +85,11 @@ export const generateRandomNodes = (n) => { } 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; +} From 8993dc706a439374d20085e8ee6d5e6b1fa685c9 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 17 Feb 2022 10:53:27 -0800 Subject: [PATCH 28/41] Redirect to Instances/{nodeId}/details page. --- awx/ui/src/screens/TopologyView/utils/helpers.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index 866b0fc029..cb185cbe61 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -1,5 +1,4 @@ import * as d3 from 'd3'; -import { InstancesAPI } from 'api'; import { truncateString } from '../../../util/strings'; import { @@ -34,13 +33,8 @@ export function renderNodeIcon(selectedNode) { } export async function redirectToDetailsPage(selectedNode, history) { - // TODO: redirect to top-level instances details page const { id: nodeId } = selectedNode; - const { - data: { results }, - } = await InstancesAPI.readInstanceGroup(nodeId); - const { id: instanceGroupId } = results[0]; - const constructedURL = `/instance_groups/${instanceGroupId}/instances/${nodeId}/details`; + const constructedURL = `/instances/${nodeId}/details`; history.push(constructedURL); } From 0d1898e72df4b76d95cfb727f321733f5ab4f7eb Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 17 Feb 2022 13:26:03 -0800 Subject: [PATCH 29/41] Add error screen. --- .../src/screens/TopologyView/TopologyView.js | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index 523f13e919..b689390090 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useState } from 'react'; import * as d3 from 'd3'; 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'; @@ -12,7 +13,7 @@ function TopologyView() { const { isLoading, result: { meshData }, - // error: fetchInitialError, + error: fetchInitialError, request: fetchMeshVisualizer, } = useRequest( useCallback(async () => { @@ -72,7 +73,6 @@ function TopologyView() { .duration(750) .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); }; - return ( <>
- - - - {!isLoading && ( - - )} - - - + {fetchInitialError ? ( + + + + + + + + ) : ( + + + + {!isLoading && ( + + )} + + + + )} ); } From ef5cd66494ef69d26ca9efe1c5b893462426034a Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Thu, 17 Feb 2022 13:58:16 -0800 Subject: [PATCH 30/41] Excise disable-lint rules. --- awx/ui/src/screens/TopologyView/Legend.js | 30 ++++++++++---------- awx/ui/src/screens/TopologyView/Tooltip.js | 26 +++++++++-------- awx/ui/src/screens/TopologyView/constants.js | 1 - 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/Legend.js b/awx/ui/src/screens/TopologyView/Legend.js index 2d729e5737..5fe35beb51 100644 --- a/awx/ui/src/screens/TopologyView/Legend.js +++ b/awx/ui/src/screens/TopologyView/Legend.js @@ -1,5 +1,5 @@ -/* eslint-disable i18next/no-literal-string */ import React from 'react'; +import { t } from '@lingui/macro'; import styled from 'styled-components'; import { Button as PFButton, @@ -62,49 +62,49 @@ function Legend() { component={TextVariants.small} style={{ fontWeight: 'bold', color: 'black' }} > - Legend + {t`Legend`} - Node types + {t`Node types`} - Control node + {t`Control node`} - Execution node + {t`Execution node`} - Hybrid node + {t`Hybrid node`} - Hop node + {t`Hop node`} - Status types + {t`Status types`} @@ -115,13 +115,13 @@ function Legend() { style={{ border: '1px solid gray', backgroundColor: '#3E8635' }} /> - Healthy + {t`Healthy`} - {nodeDetail.hostname} + + {nodeDetail.hostname} + - Type + {t`Type`} - {nodeDetail.node_type} node + {nodeDetail.node_type} {t`node`} - Status + {t`Status`} diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index f4538dea63..dbecb633f8 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -1,4 +1,3 @@ -/* eslint-disable-next-line import/prefer-default-export */ export const SELECTOR = '#chart'; export const MESH_FORCE_LAYOUT = { defaultCollisionFactor: 80, From 039c038cd70a61251cd6ba495d34ca2a97f651a2 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 22 Feb 2022 09:36:43 -0800 Subject: [PATCH 31/41] Move zoom methods into a hook. --- .../src/screens/TopologyView/TopologyView.js | 52 ++----------- awx/ui/src/screens/TopologyView/constants.js | 2 + .../src/screens/TopologyView/utils/useZoom.js | 74 +++++++++++++++++++ 3 files changed, 82 insertions(+), 46 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/utils/useZoom.js diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index b689390090..bcb1fb4a62 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -1,5 +1,4 @@ import React, { useEffect, useCallback, useState } from 'react'; -import * as d3 from 'd3'; import { t } from '@lingui/macro'; import { PageSection, Card, CardBody } from '@patternfly/react-core'; import ContentError from 'components/ContentError'; @@ -7,6 +6,8 @@ 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); @@ -27,52 +28,11 @@ function TopologyView() { useEffect(() => { fetchMeshVisualizer(); }, [fetchMeshVisualizer]); + const { zoom, zoomFit, zoomIn, zoomOut, resetZoom } = useZoom( + PARENTSELECTOR, + CHILDSELECTOR + ); - const zoom = d3.zoom().on('zoom', ({ transform }) => { - d3.select('.mesh').attr('transform', transform); - }); - const zoomIn = () => { - d3.select('.mesh-svg').transition().call(zoom.scaleBy, 2); - }; - const zoomOut = () => { - d3.select('.mesh-svg').transition().call(zoom.scaleBy, 0.5); - }; - const resetZoom = () => { - const parent = d3.select('.mesh').node().parentElement; - const width = parent.clientWidth; - const height = parent.clientHeight; - d3.select('.mesh-svg') - .transition() - .duration(750) - .call( - zoom.transform, - d3.zoomIdentity, - d3 - .zoomTransform(d3.select('.mesh-svg').node()) - .invert([width / 2, height / 2]) - ); - }; - - const zoomFit = () => { - const bounds = d3.select('.mesh').node().getBBox(); - const parent = d3.select('.mesh').node().parentElement; - const fullWidth = parent.clientWidth; - const fullHeight = parent.clientHeight; - 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('.mesh-svg') - .transition() - .duration(750) - .call(zoom.transform, d3.zoomIdentity.translate(x, y).scale(scale)); - }; return ( <>
+ * <-- parent --> + * <-- child --> + * + *
+ * 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, + }; +} From fee47fe347124e0201c88bddd9c0a752e577635c Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Mon, 21 Feb 2022 15:49:48 -0800 Subject: [PATCH 32/41] Vertically center node type symbols on Firefox. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index df93bdeddb..1a9420f907 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -130,7 +130,7 @@ function MeshGraph({ data, showLegend, zoom }) { .append('text') .text((d) => renderNodeType(d.node_type)) .attr('text-anchor', 'middle') - .attr('alignment-baseline', 'central') + .attr('dominant-baseline', 'central') .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR); // node hostname labels From 7ebf6b77e5d39bc27fa5c18f55eb48eff8ae0434 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 22 Feb 2022 13:45:30 -0800 Subject: [PATCH 33/41] Disable zoom controls until mesh layout is finalized. --- awx/ui/src/screens/TopologyView/Header.js | 5 +++++ awx/ui/src/screens/TopologyView/MeshGraph.js | 4 +++- awx/ui/src/screens/TopologyView/TopologyView.js | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/awx/ui/src/screens/TopologyView/Header.js b/awx/ui/src/screens/TopologyView/Header.js index 4929c8ceb1..1b287023e5 100644 --- a/awx/ui/src/screens/TopologyView/Header.js +++ b/awx/ui/src/screens/TopologyView/Header.js @@ -26,6 +26,7 @@ const Header = ({ zoomOut, resetZoom, zoomFit, + showZoomControls, }) => { const { light } = PageSectionVariants; return ( @@ -54,6 +55,7 @@ const Header = ({ variant="plain" icon={} onClick={zoomIn} + isDisabled={!showZoomControls} > @@ -65,6 +67,7 @@ const Header = ({ variant="plain" icon={} onClick={zoomOut} + isDisabled={!showZoomControls} > @@ -76,6 +79,7 @@ const Header = ({ variant="plain" icon={} onClick={zoomFit} + isDisabled={!showZoomControls} > @@ -87,6 +91,7 @@ const Header = ({ variant="plain" icon={} onClick={resetZoom} + isDisabled={!showZoomControls} > diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 1a9420f907..1504f4bdae 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -34,7 +34,7 @@ const Loader = styled(ContentLoading)` width: 100%; background: white; `; -function MeshGraph({ data, showLegend, zoom }) { +function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { // function MeshGraph({ showLegend, zoom }) { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); @@ -236,9 +236,11 @@ function MeshGraph({ data, showLegend, zoom }) { } function calculateAlphaDecay(a, aMin, x) { + setShowZoomControls(false); const decayPercentage = Math.min((aMin / a) * 100); if (decayPercentage >= x) { d3.select('.simulation-loader').style('visibility', 'hidden'); + setShowZoomControls(true); } } }; diff --git a/awx/ui/src/screens/TopologyView/TopologyView.js b/awx/ui/src/screens/TopologyView/TopologyView.js index bcb1fb4a62..6ef10ca9da 100644 --- a/awx/ui/src/screens/TopologyView/TopologyView.js +++ b/awx/ui/src/screens/TopologyView/TopologyView.js @@ -11,6 +11,7 @@ import { CHILDSELECTOR, PARENTSELECTOR } from './constants'; function TopologyView() { const [showLegend, setShowLegend] = useState(true); + const [showZoomControls, setShowZoomControls] = useState(false); const { isLoading, result: { meshData }, @@ -43,6 +44,7 @@ function TopologyView() { zoomOut={zoomOut} zoomFit={zoomFit} resetZoom={resetZoom} + showZoomControls={showZoomControls} /> {fetchInitialError ? ( @@ -61,6 +63,7 @@ function TopologyView() { data={meshData} showLegend={showLegend} zoom={zoom} + setShowZoomControls={setShowZoomControls} /> )} From 7fbab6760e395426e905a3fa6ab4069d14fc8d79 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 23 Feb 2022 12:37:20 -0800 Subject: [PATCH 34/41] Small layout adjustment. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 7 +++---- awx/ui/src/screens/TopologyView/constants.js | 12 +++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 1504f4bdae..fd5a17164d 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -59,12 +59,11 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { const graph = data; const simulation = d3 - .forceSimulation(graph.nodes) + .forceSimulation() + .nodes(graph.nodes) .force( 'charge', - d3 - .forceManyBody(MESH_FORCE_LAYOUT.defaultForceBody) - .strength(MESH_FORCE_LAYOUT.defaultForceStrength) + d3.forceManyBody().strength(MESH_FORCE_LAYOUT.defaultForceStrength) ) .force( 'link', diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index 590a375d5e..a19abd63cd 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -1,30 +1,24 @@ export const SELECTOR = '#chart'; export const PARENTSELECTOR = '.mesh-svg'; export const CHILDSELECTOR = '.mesh'; +export const DEFAULT_RADIUS = 16; export const MESH_FORCE_LAYOUT = { - defaultCollisionFactor: 80, - defaultForceStrength: -100, - defaultForceBody: 75, + defaultCollisionFactor: DEFAULT_RADIUS * 2 + 20, + defaultForceStrength: -30, defaultForceX: 0, defaultForceY: 0, }; - -export const DEFAULT_RADIUS = 16; 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 FALLBACK_WIDTH = 700; - export const NODE_STATE_COLOR_KEY = { disabled: '#6A6E73', healthy: '#3E8635', error: '#C9190B', }; - export const NODE_STATE_HTML_ENTITY_KEY = { disabled: '\u25EF', healthy: '\u2713', From fd135caed5007ba291d60b65f8464db4f5dce546 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 23 Feb 2022 18:54:18 -0800 Subject: [PATCH 35/41] Offload simulation calculation to web worker. --- .../screens/TopologyView/ContentLoading.js | 15 +- awx/ui/src/screens/TopologyView/MeshGraph.js | 333 +++++++++--------- awx/ui/src/screens/TopologyView/constants.js | 5 +- .../utils/workers/simulationWorker.js | 35 ++ 4 files changed, 217 insertions(+), 171 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/utils/workers/simulationWorker.js diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js index b137299c5d..cb67f6d34b 100644 --- a/awx/ui/src/screens/TopologyView/ContentLoading.js +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -4,11 +4,11 @@ import { t } from '@lingui/macro'; import styled from 'styled-components'; import { EmptyState as PFEmptyState, - EmptyStateIcon, + Progress, + ProgressMeasureLocation, Text, TextContent, TextVariants, - Spinner, } from '@patternfly/react-core'; import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons'; @@ -23,10 +23,16 @@ const TopologyIcon = styled(PFTopologyIcon)` fill: #6a6e73; `; -const ContentLoading = ({ className }) => ( +const ContentLoading = ({ className, progress }) => ( - + + ( {t`Please wait until the topology view is populated...`} - ); diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index fd5a17164d..583000a36b 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -19,7 +19,6 @@ import { // getRandomInt, } from './utils/helpers'; import { - MESH_FORCE_LAYOUT, DEFAULT_RADIUS, DEFAULT_NODE_COLOR, DEFAULT_NODE_HIGHLIGHT_COLOR, @@ -39,10 +38,12 @@ 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 data = generateRandomNodes(getRandomInt(4, 50)); const draw = () => { + setShowZoomControls(false); const width = getWidth(SELECTOR); const height = getHeight(SELECTOR); @@ -58,136 +59,164 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { const graph = data; - const simulation = d3 - .forceSimulation() - .nodes(graph.nodes) - .force( - 'charge', - d3.forceManyBody().strength(MESH_FORCE_LAYOUT.defaultForceStrength) - ) - .force( - 'link', - d3.forceLink(graph.links).id((d) => d.hostname) - ) - .force( - 'collide', - d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor) - ) - .force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX)) - .force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY)) - .force('center', d3.forceCenter(width / 2, height / 2)); + /* WEB WORKER */ + const worker = new Worker( + new URL('./utils/workers/simulationWorker.js', import.meta.url) + ); - const link = mesh - .append('g') - .attr('class', `links`) - .attr('data-cy', 'links') - .selectAll('line') - .data(graph.links) - .enter() - .append('line') - .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'); - }); + worker.postMessage({ + nodes: graph.nodes, + links: graph.links, + }); - const node = mesh - .append('g') - .attr('class', 'nodes') - .attr('data-cy', 'nodes') - .selectAll('g') - .data(graph.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); - }); + worker.onmessage = function handleWorkerEvent(event) { + switch (event.data.type) { + case 'tick': + return ticked(event.data); + case 'end': + return ended(event.data); + default: + return false; + } + }; - // node circles - node - .append('circle') - .attr('r', DEFAULT_RADIUS) - .attr('class', (d) => d.node_type) - .attr('class', (d) => `id-${d.id}`) - .attr('fill', DEFAULT_NODE_COLOR) - .attr('stroke', DEFAULT_NODE_LABEL_TEXT_COLOR); + function ticked({ progress }) { + const calculatedPercent = Math.round(progress * 100); + setSimulationProgress(calculatedPercent); + } - // node type labels - node - .append('text') - .text((d) => renderNodeType(d.node_type)) - .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') - .text((d) => renderLabelText(d.node_state, d.hostname)) - .attr('class', 'placeholder') - .attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR) - .attr('text-anchor', 'middle') - .attr('y', 40) - .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') - .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') - .attr('y', 38); - - simulation.nodes(graph.nodes).on('tick', ticked); - simulation.force('link').links(graph.links); - - function ticked() { - d3.select('.simulation-loader').style('visibility', 'visible'); - - link + 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('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.attr('transform', (d) => `translate(${d.x},${d.y})`); - calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 20); - } + // 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); - svg.call(zoom); + // 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); - function highlightSiblings(n) { - setTimeout(() => { - svg - .select(`circle.id-${n.id}`) - .attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR); - const immediate = graph.links.filter( + // 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) { + setTimeout(() => { + 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'); + }); + }, 0); + } + + 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 ); @@ -195,51 +224,27 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { svg .selectAll(`.link-${s.index}`) .transition() - .style('stroke', '#0066CC') - .style('stroke-width', '3px'); + .style('stroke', '#ccc') + .style('stroke-width', '2px'); }); - }, 0); - } - - function deselectSiblings(n) { - svg.select(`circle.id-${n.id}`).attr('fill', DEFAULT_NODE_COLOR); - const immediate = graph.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); - } - function calculateAlphaDecay(a, aMin, x) { - setShowZoomControls(false); - const decayPercentage = Math.min((aMin / a) * 100); - if (decayPercentage >= x) { - d3.select('.simulation-loader').style('visibility', 'hidden'); - setShowZoomControls(true); + 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); } } }; @@ -267,7 +272,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { redirectToDetailsPage(selectedNode, history) } /> - +
); } diff --git a/awx/ui/src/screens/TopologyView/constants.js b/awx/ui/src/screens/TopologyView/constants.js index a19abd63cd..d217078f6c 100644 --- a/awx/ui/src/screens/TopologyView/constants.js +++ b/awx/ui/src/screens/TopologyView/constants.js @@ -3,8 +3,9 @@ export const PARENTSELECTOR = '.mesh-svg'; export const CHILDSELECTOR = '.mesh'; export const DEFAULT_RADIUS = 16; export const MESH_FORCE_LAYOUT = { - defaultCollisionFactor: DEFAULT_RADIUS * 2 + 20, - defaultForceStrength: -30, + defaultCollisionFactor: DEFAULT_RADIUS * 2 + 30, + defaultForceStrength: -50, + defaultForceBody: 15, defaultForceX: 0, defaultForceY: 0, }; diff --git a/awx/ui/src/screens/TopologyView/utils/workers/simulationWorker.js b/awx/ui/src/screens/TopologyView/utils/workers/simulationWorker.js new file mode 100644 index 0000000000..95d5a1170d --- /dev/null +++ b/awx/ui/src/screens/TopologyView/utils/workers/simulationWorker.js @@ -0,0 +1,35 @@ +import * as d3 from 'd3'; +import { MESH_FORCE_LAYOUT } from '../../constants'; + +onmessage = function calculateLayout({ data: { nodes, links } }) { + const simulation = d3 + .forceSimulation(nodes) + .force( + 'charge', + d3 + .forceManyBody(MESH_FORCE_LAYOUT.defaultForceBody) + .strength(MESH_FORCE_LAYOUT.defaultForceStrength) + ) + .force( + 'link', + d3.forceLink(links).id((d) => d.hostname) + ) + .force('collide', d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor)) + .force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX)) + .force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY)) + .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 }); +}; From 4040e09cb858a269dbd70b3e38e63fcd0746c505 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 1 Mar 2022 16:25:12 -0800 Subject: [PATCH 36/41] Remove setTimeout and old comment from MeshGraph.js. --- awx/ui/src/screens/TopologyView/MeshGraph.js | 30 +++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 583000a36b..a88f5d6dc4 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import debounce from 'util/debounce'; -// import { t } from '@lingui/macro'; import * as d3 from 'd3'; import Legend from './Legend'; import Tooltip from './Tooltip'; @@ -195,23 +194,20 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { svg.call(zoom); function highlightSiblings(n) { - setTimeout(() => { + 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 - .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'); - }); - }, 0); + .selectAll(`.link-${s.index}`) + .transition() + .style('stroke', '#0066CC') + .style('stroke-width', '3px'); + }); } function deselectSiblings(n) { From 079eed2b9eb8f0150e046a6c6d292f7611b1143e Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 8 Mar 2022 10:13:47 -0800 Subject: [PATCH 37/41] Mock web worker. --- awx/ui/src/App.test.js | 1 + awx/ui/src/index.test.js | 1 + awx/ui/src/routeConfig.test.js | 1 + awx/ui/src/screens/TopologyView/MeshGraph.js | 6 ++---- awx/ui/src/screens/TopologyView/utils/webWorker.js | 3 +++ 5 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 awx/ui/src/screens/TopologyView/utils/webWorker.js diff --git a/awx/ui/src/App.test.js b/awx/ui/src/App.test.js index de080062fd..bdc71de49f 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('screens/TopologyView/utils/WebWorker', () => jest.fn()); describe('', () => { beforeEach(() => { diff --git a/awx/ui/src/index.test.js b/awx/ui/src/index.test.js index 49ae9e2317..ffde7d7d9c 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('screens/TopologyView/utils/WebWorker', () => jest.fn()); describe('index.jsx', () => { it('renders ok', () => { diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js index 35e0a5eae3..643bd13dfd 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('screens/TopologyView/utils/WebWorker', () => jest.fn()); const userProfile = { isSuperUser: false, diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index a88f5d6dc4..d643044e68 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -17,6 +17,7 @@ import { // generateRandomNodes, // getRandomInt, } from './utils/helpers'; +import webWorker from './utils/webWorker'; import { DEFAULT_RADIUS, DEFAULT_NODE_COLOR, @@ -59,10 +60,7 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { const graph = data; /* WEB WORKER */ - const worker = new Worker( - new URL('./utils/workers/simulationWorker.js', import.meta.url) - ); - + const worker = webWorker(); worker.postMessage({ nodes: graph.nodes, links: graph.links, diff --git a/awx/ui/src/screens/TopologyView/utils/webWorker.js b/awx/ui/src/screens/TopologyView/utils/webWorker.js new file mode 100644 index 0000000000..7cc564b1c5 --- /dev/null +++ b/awx/ui/src/screens/TopologyView/utils/webWorker.js @@ -0,0 +1,3 @@ +export default function webWorker() { + return new Worker(new URL('./workers/simulationWorker.js', import.meta.url)); +} From 7a6fd2623e6e674bed485858642514f431648561 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 8 Mar 2022 12:34:05 -0800 Subject: [PATCH 38/41] Move web worker out of /screens directory. --- awx/ui/src/App.test.js | 2 +- awx/ui/src/index.test.js | 2 +- awx/ui/src/routeConfig.test.js | 2 +- awx/ui/src/screens/TopologyView/MeshGraph.js | 2 +- awx/ui/src/screens/TopologyView/utils/webWorker.js | 3 --- awx/ui/src/util/webWorker.js | 8 ++++++++ 6 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 awx/ui/src/screens/TopologyView/utils/webWorker.js create mode 100644 awx/ui/src/util/webWorker.js diff --git a/awx/ui/src/App.test.js b/awx/ui/src/App.test.js index bdc71de49f..e1f2fb3bc3 100644 --- a/awx/ui/src/App.test.js +++ b/awx/ui/src/App.test.js @@ -7,7 +7,7 @@ import { mountWithContexts } from '../testUtils/enzymeHelpers'; import App, { ProtectedRoute } from './App'; jest.mock('./api'); -jest.mock('screens/TopologyView/utils/WebWorker', () => jest.fn()); +jest.mock('util/webWorker', () => jest.fn()); describe('', () => { beforeEach(() => { diff --git a/awx/ui/src/index.test.js b/awx/ui/src/index.test.js index ffde7d7d9c..a0419c9933 100644 --- a/awx/ui/src/index.test.js +++ b/awx/ui/src/index.test.js @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import App from './App'; jest.mock('react-dom', () => ({ render: jest.fn() })); -jest.mock('screens/TopologyView/utils/WebWorker', () => jest.fn()); +jest.mock('util/webWorker', () => jest.fn()); describe('index.jsx', () => { it('renders ok', () => { diff --git a/awx/ui/src/routeConfig.test.js b/awx/ui/src/routeConfig.test.js index 643bd13dfd..5a7def4348 100644 --- a/awx/ui/src/routeConfig.test.js +++ b/awx/ui/src/routeConfig.test.js @@ -1,5 +1,5 @@ import getRouteConfig from './routeConfig'; -jest.mock('screens/TopologyView/utils/WebWorker', () => jest.fn()); +jest.mock('util/webWorker', () => jest.fn()); const userProfile = { isSuperUser: false, diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index d643044e68..2d7700b45e 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -17,7 +17,7 @@ import { // generateRandomNodes, // getRandomInt, } from './utils/helpers'; -import webWorker from './utils/webWorker'; +import webWorker from '../../util/webWorker'; import { DEFAULT_RADIUS, DEFAULT_NODE_COLOR, diff --git a/awx/ui/src/screens/TopologyView/utils/webWorker.js b/awx/ui/src/screens/TopologyView/utils/webWorker.js deleted file mode 100644 index 7cc564b1c5..0000000000 --- a/awx/ui/src/screens/TopologyView/utils/webWorker.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function webWorker() { - return new Worker(new URL('./workers/simulationWorker.js', import.meta.url)); -} diff --git a/awx/ui/src/util/webWorker.js b/awx/ui/src/util/webWorker.js new file mode 100644 index 0000000000..64c2eac037 --- /dev/null +++ b/awx/ui/src/util/webWorker.js @@ -0,0 +1,8 @@ +export default function webWorker() { + return new Worker( + new URL( + 'screens/TopologyView/utils/workers/simulationWorker.js', + import.meta.url + ) + ); +} From 8bf9dd038eb1cb647bb059d1c3d5b78601f41937 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Tue, 8 Mar 2022 12:58:04 -0800 Subject: [PATCH 39/41] Address review feedback. --- awx/ui/src/screens/TopologyView/ContentLoading.js | 1 - awx/ui/src/screens/TopologyView/MeshGraph.js | 4 ---- awx/ui/src/screens/TopologyView/utils/helpers.js | 2 +- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js index cb67f6d34b..c4b07bf9f0 100644 --- a/awx/ui/src/screens/TopologyView/ContentLoading.js +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -43,5 +43,4 @@ const ContentLoading = ({ className, progress }) => ( ); -export { ContentLoading as _ContentLoading }; export default ContentLoading; diff --git a/awx/ui/src/screens/TopologyView/MeshGraph.js b/awx/ui/src/screens/TopologyView/MeshGraph.js index 2d7700b45e..01ed117f7b 100644 --- a/awx/ui/src/screens/TopologyView/MeshGraph.js +++ b/awx/ui/src/screens/TopologyView/MeshGraph.js @@ -14,8 +14,6 @@ import { redirectToDetailsPage, getHeight, getWidth, - // generateRandomNodes, - // getRandomInt, } from './utils/helpers'; import webWorker from '../../util/webWorker'; import { @@ -34,14 +32,12 @@ const Loader = styled(ContentLoading)` background: white; `; function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { - // function MeshGraph({ showLegend, zoom }) { const [isNodeSelected, setIsNodeSelected] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState(null); const [simulationProgress, setSimulationProgress] = useState(null); const history = useHistory(); - // const data = generateRandomNodes(getRandomInt(4, 50)); const draw = () => { setShowZoomControls(false); const width = getWidth(SELECTOR); diff --git a/awx/ui/src/screens/TopologyView/utils/helpers.js b/awx/ui/src/screens/TopologyView/utils/helpers.js index cb185cbe61..f8dee9866c 100644 --- a/awx/ui/src/screens/TopologyView/utils/helpers.js +++ b/awx/ui/src/screens/TopologyView/utils/helpers.js @@ -32,7 +32,7 @@ export function renderNodeIcon(selectedNode) { return false; } -export async function redirectToDetailsPage(selectedNode, history) { +export function redirectToDetailsPage(selectedNode, history) { const { id: nodeId } = selectedNode; const constructedURL = `/instances/${nodeId}/details`; history.push(constructedURL); From e4f0153a7de744facc073a07005c7700cfe7660b Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 9 Mar 2022 06:58:43 -0800 Subject: [PATCH 40/41] Remove import statements from web worker file. --- awx/ui/src/util/simulationWorker.js | 34 +++++++++++++++++++++++++++++ awx/ui/src/util/webWorker.js | 7 +----- 2 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 awx/ui/src/util/simulationWorker.js 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/webWorker.js b/awx/ui/src/util/webWorker.js index 64c2eac037..7babb68f38 100644 --- a/awx/ui/src/util/webWorker.js +++ b/awx/ui/src/util/webWorker.js @@ -1,8 +1,3 @@ export default function webWorker() { - return new Worker( - new URL( - 'screens/TopologyView/utils/workers/simulationWorker.js', - import.meta.url - ) - ); + return new Worker(new URL('./simulationWorker.js', import.meta.url)); } From 23f6fae27aeb56929faddf32080fa89d815987aa Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 9 Mar 2022 07:36:04 -0800 Subject: [PATCH 41/41] Add data-cy to content loader; move simulatioWorker to /util directory. --- .../screens/TopologyView/ContentLoading.js | 2 +- .../utils/workers/simulationWorker.js | 35 ------------------- 2 files changed, 1 insertion(+), 36 deletions(-) delete mode 100644 awx/ui/src/screens/TopologyView/utils/workers/simulationWorker.js diff --git a/awx/ui/src/screens/TopologyView/ContentLoading.js b/awx/ui/src/screens/TopologyView/ContentLoading.js index c4b07bf9f0..656edfe505 100644 --- a/awx/ui/src/screens/TopologyView/ContentLoading.js +++ b/awx/ui/src/screens/TopologyView/ContentLoading.js @@ -24,7 +24,7 @@ const TopologyIcon = styled(PFTopologyIcon)` `; const ContentLoading = ({ className, progress }) => ( - + d.hostname) - ) - .force('collide', d3.forceCollide(MESH_FORCE_LAYOUT.defaultCollisionFactor)) - .force('forceX', d3.forceX(MESH_FORCE_LAYOUT.defaultForceX)) - .force('forceY', d3.forceY(MESH_FORCE_LAYOUT.defaultForceY)) - .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 }); -};