From fd135caed5007ba291d60b65f8464db4f5dce546 Mon Sep 17 00:00:00 2001 From: Kia Lam Date: Wed, 23 Feb 2022 18:54:18 -0800 Subject: [PATCH] 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 }); +};