mirror of
https://github.com/ansible/awx.git
synced 2026-03-28 22:35:08 -02:30
Offload simulation calculation to web worker.
This commit is contained in:
@@ -4,11 +4,11 @@ import { t } from '@lingui/macro';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
EmptyState as PFEmptyState,
|
EmptyState as PFEmptyState,
|
||||||
EmptyStateIcon,
|
Progress,
|
||||||
|
ProgressMeasureLocation,
|
||||||
Text,
|
Text,
|
||||||
TextContent,
|
TextContent,
|
||||||
TextVariants,
|
TextVariants,
|
||||||
Spinner,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons';
|
import { TopologyIcon as PFTopologyIcon } from '@patternfly/react-icons';
|
||||||
@@ -23,10 +23,16 @@ const TopologyIcon = styled(PFTopologyIcon)`
|
|||||||
fill: #6a6e73;
|
fill: #6a6e73;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentLoading = ({ className }) => (
|
const ContentLoading = ({ className, progress }) => (
|
||||||
<EmptyState variant="full" className={className}>
|
<EmptyState variant="full" className={className}>
|
||||||
<TopologyIcon />
|
<TopologyIcon />
|
||||||
<TextContent>
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
measureLocation={ProgressMeasureLocation.inside}
|
||||||
|
aria-label={t`content-loading-in-progress`}
|
||||||
|
style={{ margin: '20px' }}
|
||||||
|
/>
|
||||||
|
<TextContent style={{ margin: '20px' }}>
|
||||||
<Text
|
<Text
|
||||||
component={TextVariants.small}
|
component={TextVariants.small}
|
||||||
style={{ fontWeight: 'bold', color: 'black' }}
|
style={{ fontWeight: 'bold', color: 'black' }}
|
||||||
@@ -34,7 +40,6 @@ const ContentLoading = ({ className }) => (
|
|||||||
{t`Please wait until the topology view is populated...`}
|
{t`Please wait until the topology view is populated...`}
|
||||||
</Text>
|
</Text>
|
||||||
</TextContent>
|
</TextContent>
|
||||||
<EmptyStateIcon variant="container" component={Spinner} />
|
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
// getRandomInt,
|
// getRandomInt,
|
||||||
} from './utils/helpers';
|
} from './utils/helpers';
|
||||||
import {
|
import {
|
||||||
MESH_FORCE_LAYOUT,
|
|
||||||
DEFAULT_RADIUS,
|
DEFAULT_RADIUS,
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
DEFAULT_NODE_HIGHLIGHT_COLOR,
|
DEFAULT_NODE_HIGHLIGHT_COLOR,
|
||||||
@@ -39,10 +38,12 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
const [isNodeSelected, setIsNodeSelected] = useState(false);
|
const [isNodeSelected, setIsNodeSelected] = useState(false);
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
const [nodeDetail, setNodeDetail] = useState(null);
|
const [nodeDetail, setNodeDetail] = useState(null);
|
||||||
|
const [simulationProgress, setSimulationProgress] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
// const data = generateRandomNodes(getRandomInt(4, 50));
|
// const data = generateRandomNodes(getRandomInt(4, 50));
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
|
setShowZoomControls(false);
|
||||||
const width = getWidth(SELECTOR);
|
const width = getWidth(SELECTOR);
|
||||||
const height = getHeight(SELECTOR);
|
const height = getHeight(SELECTOR);
|
||||||
|
|
||||||
@@ -58,136 +59,164 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
|
|
||||||
const graph = data;
|
const graph = data;
|
||||||
|
|
||||||
const simulation = d3
|
/* WEB WORKER */
|
||||||
.forceSimulation()
|
const worker = new Worker(
|
||||||
.nodes(graph.nodes)
|
new URL('./utils/workers/simulationWorker.js', import.meta.url)
|
||||||
.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));
|
|
||||||
|
|
||||||
const link = mesh
|
worker.postMessage({
|
||||||
.append('g')
|
nodes: graph.nodes,
|
||||||
.attr('class', `links`)
|
links: graph.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');
|
|
||||||
});
|
|
||||||
|
|
||||||
const node = mesh
|
worker.onmessage = function handleWorkerEvent(event) {
|
||||||
.append('g')
|
switch (event.data.type) {
|
||||||
.attr('class', 'nodes')
|
case 'tick':
|
||||||
.attr('data-cy', 'nodes')
|
return ticked(event.data);
|
||||||
.selectAll('g')
|
case 'end':
|
||||||
.data(graph.nodes)
|
return ended(event.data);
|
||||||
.enter()
|
default:
|
||||||
.append('g')
|
return false;
|
||||||
.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 circles
|
function ticked({ progress }) {
|
||||||
node
|
const calculatedPercent = Math.round(progress * 100);
|
||||||
.append('circle')
|
setSimulationProgress(calculatedPercent);
|
||||||
.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);
|
|
||||||
|
|
||||||
// node type labels
|
function ended({ nodes, links }) {
|
||||||
node
|
// Remove loading screen
|
||||||
.append('text')
|
d3.select('.simulation-loader').style('visibility', 'hidden');
|
||||||
.text((d) => renderNodeType(d.node_type))
|
setShowZoomControls(true);
|
||||||
.attr('text-anchor', 'middle')
|
// Center the mesh
|
||||||
.attr('dominant-baseline', 'central')
|
const simulation = d3
|
||||||
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR);
|
.forceSimulation(nodes)
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2));
|
||||||
// node hostname labels
|
simulation.tick();
|
||||||
const hostNames = node.append('g');
|
// Add links
|
||||||
hostNames
|
mesh
|
||||||
.append('text')
|
.append('g')
|
||||||
.text((d) => renderLabelText(d.node_state, d.hostname))
|
.attr('class', `links`)
|
||||||
.attr('class', 'placeholder')
|
.attr('data-cy', 'links')
|
||||||
.attr('fill', DEFAULT_NODE_LABEL_TEXT_COLOR)
|
.selectAll('line')
|
||||||
.attr('text-anchor', 'middle')
|
.data(links)
|
||||||
.attr('y', 40)
|
.enter()
|
||||||
.each(function calculateLabelWidth() {
|
.append('line')
|
||||||
// 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
|
|
||||||
.attr('x1', (d) => d.source.x)
|
.attr('x1', (d) => d.source.x)
|
||||||
.attr('y1', (d) => d.source.y)
|
.attr('y1', (d) => d.source.y)
|
||||||
.attr('x2', (d) => d.target.x)
|
.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})`);
|
// node circles
|
||||||
calculateAlphaDecay(simulation.alpha(), simulation.alphaMin(), 20);
|
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) {
|
// node hostname labels
|
||||||
setTimeout(() => {
|
const hostNames = node.append('g');
|
||||||
svg
|
hostNames
|
||||||
.select(`circle.id-${n.id}`)
|
.append('text')
|
||||||
.attr('fill', DEFAULT_NODE_HIGHLIGHT_COLOR);
|
.attr('x', (d) => d.x)
|
||||||
const immediate = graph.links.filter(
|
.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) =>
|
(l) =>
|
||||||
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
||||||
);
|
);
|
||||||
@@ -195,51 +224,27 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
|
|||||||
svg
|
svg
|
||||||
.selectAll(`.link-${s.index}`)
|
.selectAll(`.link-${s.index}`)
|
||||||
.transition()
|
.transition()
|
||||||
.style('stroke', '#0066CC')
|
.style('stroke', '#ccc')
|
||||||
.style('stroke-width', '3px');
|
.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) {
|
function highlightSelected(n) {
|
||||||
setShowZoomControls(false);
|
if (svg.select(`circle.id-${n.id}`).attr('stroke-width') !== null) {
|
||||||
const decayPercentage = Math.min((aMin / a) * 100);
|
// toggle rings
|
||||||
if (decayPercentage >= x) {
|
svg.select(`circle.id-${n.id}`).attr('stroke-width', null);
|
||||||
d3.select('.simulation-loader').style('visibility', 'hidden');
|
// show default empty state of tooltip
|
||||||
setShowZoomControls(true);
|
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)
|
redirectToDetailsPage(selectedNode, history)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Loader className="simulation-loader" />
|
<Loader className="simulation-loader" progress={simulationProgress} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ export const PARENTSELECTOR = '.mesh-svg';
|
|||||||
export const CHILDSELECTOR = '.mesh';
|
export const CHILDSELECTOR = '.mesh';
|
||||||
export const DEFAULT_RADIUS = 16;
|
export const DEFAULT_RADIUS = 16;
|
||||||
export const MESH_FORCE_LAYOUT = {
|
export const MESH_FORCE_LAYOUT = {
|
||||||
defaultCollisionFactor: DEFAULT_RADIUS * 2 + 20,
|
defaultCollisionFactor: DEFAULT_RADIUS * 2 + 30,
|
||||||
defaultForceStrength: -30,
|
defaultForceStrength: -50,
|
||||||
|
defaultForceBody: 15,
|
||||||
defaultForceX: 0,
|
defaultForceX: 0,
|
||||||
defaultForceY: 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 });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user