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 });
+};