mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 19:10:07 -03:30
Offload simulation calculation to web worker.
This commit is contained in:
parent
7fbab6760e
commit
fd135caed5
@ -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 }) => (
|
||||
<EmptyState variant="full" className={className}>
|
||||
<TopologyIcon />
|
||||
<TextContent>
|
||||
<Progress
|
||||
value={progress}
|
||||
measureLocation={ProgressMeasureLocation.inside}
|
||||
aria-label={t`content-loading-in-progress`}
|
||||
style={{ margin: '20px' }}
|
||||
/>
|
||||
<TextContent style={{ margin: '20px' }}>
|
||||
<Text
|
||||
component={TextVariants.small}
|
||||
style={{ fontWeight: 'bold', color: 'black' }}
|
||||
@ -34,7 +40,6 @@ const ContentLoading = ({ className }) => (
|
||||
{t`Please wait until the topology view is populated...`}
|
||||
</Text>
|
||||
</TextContent>
|
||||
<EmptyStateIcon variant="container" component={Spinner} />
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
/>
|
||||
<Loader className="simulation-loader" />
|
||||
<Loader className="simulation-loader" progress={simulationProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user