mirror of
https://github.com/ansible/awx.git
synced 2026-03-01 00:38:45 -03:30
WIP new mesh layout based on QE feedback.
This commit is contained in:
139
awx/ui/src/screens/TopologyView/Legend.js
Normal file
139
awx/ui/src/screens/TopologyView/Legend.js
Normal file
@@ -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 (
|
||||||
|
<Wrapper class="legend" data-cy="legend">
|
||||||
|
<TextContent>
|
||||||
|
<Text
|
||||||
|
component={TextVariants.small}
|
||||||
|
style={{ 'font-weight': 'bold', color: 'black' }}
|
||||||
|
>
|
||||||
|
Legend
|
||||||
|
</Text>
|
||||||
|
<Divider component="div" />
|
||||||
|
<Text component={TextVariants.small}>Node types</Text>
|
||||||
|
</TextContent>
|
||||||
|
<DescriptionList isHorizontal isFluid>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="primary" isSmall>
|
||||||
|
C
|
||||||
|
</Button>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Control node</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="primary" isSmall>
|
||||||
|
Ex
|
||||||
|
</Button>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>
|
||||||
|
Execution node
|
||||||
|
</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="primary" isSmall>
|
||||||
|
H
|
||||||
|
</Button>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Hybrid node</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="primary" isSmall>
|
||||||
|
h
|
||||||
|
</Button>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Hop node</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
</DescriptionList>
|
||||||
|
<TextContent>
|
||||||
|
<Text component={TextVariants.small}>Status types</Text>
|
||||||
|
</TextContent>
|
||||||
|
<DescriptionList isHorizontal isFluid>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button
|
||||||
|
icon={<CheckIcon />}
|
||||||
|
isSmall
|
||||||
|
style={{ border: '1px solid gray', backgroundColor: '#3E8635' }}
|
||||||
|
/>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Healthy</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="danger" icon={<ExclamationIcon />} isSmall />
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Error</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button
|
||||||
|
isSmall
|
||||||
|
style={{ border: '1px solid gray', backgroundColor: '#e6e6e6' }}
|
||||||
|
/>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>Disabled</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
</DescriptionList>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Legend;
|
||||||
@@ -1,90 +1,115 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { InstancesAPI } from 'api';
|
||||||
import debounce from 'util/debounce';
|
import debounce from 'util/debounce';
|
||||||
// import { t } from '@lingui/macro';
|
// import { t } from '@lingui/macro';
|
||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
|
import Legend from './Legend';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
// function MeshGraph({ data }) {
|
function MeshGraph({ data }) {
|
||||||
function MeshGraph({ redirectToDetailsPage }) {
|
// function MeshGraph() {
|
||||||
const draw = useCallback(() => {
|
const [isNodeSelected, setIsNodeSelected] = useState(false);
|
||||||
const data = {
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
nodes: [
|
const [nodeDetail, setNodeDetail] = useState(null);
|
||||||
{
|
const history = useHistory();
|
||||||
id: 1,
|
|
||||||
hostname: 'awx_1',
|
const draw = () => {
|
||||||
node_type: 'hybrid',
|
// const data = {
|
||||||
node_state: 'healthy',
|
// nodes: [
|
||||||
},
|
// {
|
||||||
{
|
// id: 1,
|
||||||
id: 3,
|
// hostname: 'awx_1',
|
||||||
hostname: 'receptor-1',
|
// node_type: 'hybrid',
|
||||||
node_type: 'execution',
|
// node_state: 'healthy',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 3,
|
||||||
id: 4,
|
// hostname: 'receptor-1',
|
||||||
hostname: 'receptor-2',
|
// node_type: 'execution',
|
||||||
node_type: 'execution',
|
// node_state: 'healthy',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 4,
|
||||||
id: 2,
|
// hostname: 'receptor-2',
|
||||||
hostname: 'receptor-hop',
|
// node_type: 'execution',
|
||||||
node_type: 'hop',
|
// node_state: 'healthy',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 2,
|
||||||
id: 5,
|
// hostname: 'receptor-hop',
|
||||||
hostname: 'receptor-hop-1',
|
// node_type: 'hop',
|
||||||
node_type: 'hop',
|
// node_state: 'healthy',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 5,
|
||||||
id: 6,
|
// hostname: 'receptor-hop-1',
|
||||||
hostname: 'receptor-hop-2',
|
// node_type: 'hop',
|
||||||
node_type: 'hop',
|
// node_state: 'healthy',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 6,
|
||||||
id: 7,
|
// hostname: 'receptor-hop-2',
|
||||||
hostname: 'receptor-hop-3',
|
// node_type: 'hop',
|
||||||
node_type: 'hop',
|
// node_state: 'disabled',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
{
|
// id: 7,
|
||||||
id: 8,
|
// hostname: 'receptor-hop-3',
|
||||||
hostname: 'receptor-hop-4',
|
// node_type: 'hop',
|
||||||
node_type: 'hop',
|
// node_state: 'error',
|
||||||
node_state: 'healthy',
|
// },
|
||||||
},
|
// {
|
||||||
],
|
// id: 8,
|
||||||
links: [
|
// hostname: 'receptor-hop-4',
|
||||||
{
|
// node_type: 'hop',
|
||||||
source: 'receptor-hop',
|
// node_state: 'healthy',
|
||||||
target: 'awx_1',
|
// },
|
||||||
},
|
// ],
|
||||||
{
|
// links: [
|
||||||
source: 'receptor-1',
|
// {
|
||||||
target: 'receptor-hop',
|
// source: 'receptor-hop',
|
||||||
},
|
// target: 'awx_1',
|
||||||
{
|
// },
|
||||||
source: 'receptor-2',
|
// {
|
||||||
target: 'receptor-hop',
|
// source: 'receptor-1',
|
||||||
},
|
// target: 'receptor-hop',
|
||||||
{
|
// },
|
||||||
source: 'receptor-hop-3',
|
// {
|
||||||
target: 'receptor-hop',
|
// source: 'receptor-2',
|
||||||
},
|
// target: 'receptor-hop',
|
||||||
// {
|
// },
|
||||||
// "source": "receptor-2",
|
// {
|
||||||
// "target": "receptor-hop-1"
|
// source: 'receptor-hop-3',
|
||||||
// },
|
// target: 'receptor-hop',
|
||||||
// {
|
// },
|
||||||
// "source": "receptor-2",
|
// // {
|
||||||
// "target": "receptor-hop-2"
|
// // "source": "receptor-hop",
|
||||||
// }
|
// // "target": "receptor-hop-1"
|
||||||
],
|
// // },
|
||||||
};
|
// // {
|
||||||
const margin = 80;
|
// // "source": "receptor-1",
|
||||||
|
// // "target": "receptor-hop-2"
|
||||||
|
// // },
|
||||||
|
// // {
|
||||||
|
// // "source": "awx_1",
|
||||||
|
// // "target": "receptor-hop-4"
|
||||||
|
// // }
|
||||||
|
// ],
|
||||||
|
// };
|
||||||
|
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 getWidth = () => {
|
const getWidth = () => {
|
||||||
let width;
|
let width;
|
||||||
// This is in an a try/catch due to an error from jest.
|
// This is in an a try/catch due to an error from jest.
|
||||||
@@ -92,27 +117,25 @@ function MeshGraph({ redirectToDetailsPage }) {
|
|||||||
// style function, it says it is null in the test
|
// style function, it says it is null in the test
|
||||||
try {
|
try {
|
||||||
width =
|
width =
|
||||||
parseInt(d3.select(`#chart`).style('width'), 10) - margin || 700;
|
parseInt(d3.select(`#chart`).style('width'), 10) - margin ||
|
||||||
|
fallbackWidth;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
width = 700;
|
width = fallbackWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
return width;
|
return width;
|
||||||
};
|
};
|
||||||
const width = getWidth();
|
const width = getWidth();
|
||||||
const height = 600;
|
|
||||||
const defaultRadius = 6;
|
|
||||||
const highlightRadius = 9;
|
|
||||||
|
|
||||||
const zoom = d3
|
// const zoom = d3
|
||||||
.zoom()
|
// .zoom()
|
||||||
// .scaleExtent([1, 8])
|
// // .scaleExtent([1, 8])
|
||||||
.on('zoom', (event) => {
|
// .on('zoom', (event) => {
|
||||||
svg.attr('transform', event.transform);
|
// svg.attr('transform', event.transform);
|
||||||
});
|
// });
|
||||||
|
|
||||||
/* Add SVG */
|
/* Add SVG */
|
||||||
d3.selectAll(`#chart > *`).remove();
|
d3.selectAll(`#chart > svg`).remove();
|
||||||
|
|
||||||
const svg = d3
|
const svg = d3
|
||||||
.select('#chart')
|
.select('#chart')
|
||||||
@@ -120,48 +143,34 @@ function MeshGraph({ redirectToDetailsPage }) {
|
|||||||
.attr('width', `${width + margin}px`)
|
.attr('width', `${width + margin}px`)
|
||||||
.attr('height', `${height + margin}px`)
|
.attr('height', `${height + margin}px`)
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('transform', `translate(${margin}, ${margin})`)
|
.attr('transform', `translate(${margin}, ${margin})`);
|
||||||
.call(zoom);
|
// .call(zoom);
|
||||||
|
|
||||||
const graph = data;
|
const graph = data;
|
||||||
|
|
||||||
const simulation = d3
|
const simulation = d3
|
||||||
.forceSimulation()
|
.forceSimulation()
|
||||||
.force('charge', d3.forceManyBody(75).strength(-100))
|
.force(
|
||||||
|
'charge',
|
||||||
|
d3.forceManyBody(defaultForceBody).strength(defaultForceStrength)
|
||||||
|
)
|
||||||
.force(
|
.force(
|
||||||
'link',
|
'link',
|
||||||
d3.forceLink().id((d) => d.hostname)
|
d3.forceLink().id((d) => d.hostname)
|
||||||
)
|
)
|
||||||
.force('collide', d3.forceCollide(80))
|
.force('collide', d3.forceCollide(defaultCollisionFactor))
|
||||||
.force('forceX', d3.forceX(0))
|
.force('forceX', d3.forceX(defaultForceX))
|
||||||
.force('forceY', d3.forceY(0))
|
.force('forceY', d3.forceY(defaultForceY))
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2));
|
.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
|
const link = svg
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', `links`)
|
.attr('class', `links`)
|
||||||
.attr('data-cy', 'links')
|
.attr('data-cy', 'links')
|
||||||
// .selectAll('path')
|
|
||||||
.selectAll('line')
|
.selectAll('line')
|
||||||
.data(graph.links)
|
.data(graph.links)
|
||||||
.enter()
|
.enter()
|
||||||
.append('line')
|
.append('line')
|
||||||
// .append('path')
|
|
||||||
.attr('class', (d, i) => `link-${i}`)
|
.attr('class', (d, i) => `link-${i}`)
|
||||||
.attr('data-cy', (d) => `${d.source}-${d.target}`)
|
.attr('data-cy', (d) => `${d.source}-${d.target}`)
|
||||||
.style('fill', 'none')
|
.style('fill', 'none')
|
||||||
@@ -183,99 +192,62 @@ function MeshGraph({ redirectToDetailsPage }) {
|
|||||||
.on('mouseenter', function handleNodeHover(_, d) {
|
.on('mouseenter', function handleNodeHover(_, d) {
|
||||||
d3.select(this).transition().style('cursor', 'pointer');
|
d3.select(this).transition().style('cursor', 'pointer');
|
||||||
highlightSiblings(d);
|
highlightSiblings(d);
|
||||||
tooltip
|
|
||||||
.html(
|
|
||||||
`<h3>Details</h3> <hr>name: ${d.hostname} <br>type: ${d.node_type} <br>status: ${d.node_state} <br> <a>Click on a node to view the details</a>`
|
|
||||||
)
|
|
||||||
.style('visibility', 'visible');
|
|
||||||
})
|
})
|
||||||
.on('mouseleave', (_, d) => {
|
.on('mouseleave', (_, d) => {
|
||||||
deselectSiblings(d);
|
deselectSiblings(d);
|
||||||
tooltip.html(``).style('visibility', 'hidden');
|
|
||||||
})
|
})
|
||||||
.on('click', (_, d) => {
|
.on('click', (_, d) => {
|
||||||
if (d.node_type !== 'hop') {
|
setNodeDetail(d);
|
||||||
redirectToDetailsPage(d);
|
highlightSelected(d);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// health rings on nodes
|
// node circles
|
||||||
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));
|
|
||||||
|
|
||||||
// inner node ring
|
|
||||||
node
|
node
|
||||||
.append('circle')
|
.append('circle')
|
||||||
.attr('r', defaultRadius)
|
.attr('r', defaultRadius)
|
||||||
.attr('class', (d) => d.node_type)
|
.attr('class', (d) => d.node_type)
|
||||||
.attr('class', (d) => `id-${d.id}`)
|
.attr('class', (d) => `id-${d.id}`)
|
||||||
.attr('fill', (d) => renderNodeColor(d.node_type))
|
.attr('fill', defaultNodeColor)
|
||||||
.attr('stroke', 'white');
|
.attr('stroke', defaultNodeLabelColor);
|
||||||
svg.call(expandGlow);
|
|
||||||
|
|
||||||
// legend
|
// node type labels
|
||||||
svg.append('text').attr('x', 10).attr('y', 20).text('Legend');
|
|
||||||
|
|
||||||
svg
|
|
||||||
.append('g')
|
|
||||||
.selectAll('g')
|
|
||||||
.attr('class', 'chart-legend')
|
|
||||||
.attr('data-cy', 'chart-legend')
|
|
||||||
.data(graph.nodes)
|
|
||||||
.enter()
|
|
||||||
.append('circle')
|
|
||||||
.attr('cx', 10)
|
|
||||||
.attr('cy', (d, i) => 50 + i * 25)
|
|
||||||
.attr('r', defaultRadius)
|
|
||||||
.attr('class', (d) => d.node_type)
|
|
||||||
.style('fill', (d) => renderNodeColor(d.node_type));
|
|
||||||
|
|
||||||
// legend text
|
|
||||||
svg
|
|
||||||
.append('g')
|
|
||||||
.attr('class', 'chart-text')
|
|
||||||
.attr('data-cy', 'chart-text')
|
|
||||||
.selectAll('g')
|
|
||||||
.data(graph.nodes)
|
|
||||||
.enter()
|
|
||||||
.append('text')
|
|
||||||
.attr('x', 20)
|
|
||||||
.attr('y', (d, i) => 50 + 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')
|
|
||||||
.attr('data-cy', 'd3-tooltip')
|
|
||||||
.style('position', 'absolute')
|
|
||||||
.style('top', '200px')
|
|
||||||
.style('right', '40px')
|
|
||||||
.style('z-index', '10')
|
|
||||||
.style('visibility', 'hidden')
|
|
||||||
.style('padding', '15px')
|
|
||||||
// .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')
|
|
||||||
.style('color', '#e6e6e')
|
|
||||||
.text('');
|
|
||||||
|
|
||||||
// node labels
|
|
||||||
node
|
node
|
||||||
.append('text')
|
.append('text')
|
||||||
.text((d) => d.hostname)
|
.text((d) => renderNodeType(d.node_type))
|
||||||
.attr('x', 16)
|
.attr('text-anchor', 'middle')
|
||||||
.attr('y', 3);
|
.attr('alignment-baseline', 'central')
|
||||||
|
.attr('fill', defaultNodeLabelColor);
|
||||||
|
|
||||||
|
// node hostname labels
|
||||||
|
const hostNames = node.append('g');
|
||||||
|
hostNames
|
||||||
|
.append('text')
|
||||||
|
.text((d) => renderLabelText(d.node_state, d.hostname))
|
||||||
|
.attr('fill', defaultNodeLabelColor)
|
||||||
|
.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));
|
||||||
|
});
|
||||||
|
|
||||||
|
hostNames
|
||||||
|
.append('text')
|
||||||
|
.text((d) => renderLabelText(d.node_state, d.hostname))
|
||||||
|
.attr('font-size', defaultFontSize)
|
||||||
|
.attr('fill', defaultNodeLabelColor)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('y', 38);
|
||||||
|
|
||||||
simulation.nodes(graph.nodes).on('tick', ticked);
|
simulation.nodes(graph.nodes).on('tick', ticked);
|
||||||
simulation.force('link').links(graph.links);
|
simulation.force('link').links(graph.links);
|
||||||
@@ -291,54 +263,37 @@ function MeshGraph({ redirectToDetailsPage }) {
|
|||||||
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
|
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// function linkArc(d) {
|
function renderStateColor(nodeState) {
|
||||||
// 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
|
|
||||||
.selectAll('.healthy')
|
|
||||||
.transition()
|
|
||||||
.duration(1000)
|
|
||||||
.attr('stroke-width', '1px')
|
|
||||||
.on('end', expandGlow);
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandGlow() {
|
|
||||||
svg
|
|
||||||
.selectAll('.healthy')
|
|
||||||
.transition()
|
|
||||||
.duration(1000)
|
|
||||||
.attr('stroke-width', '4.5px')
|
|
||||||
.on('end', contractGlow);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHealthColor(nodeState) {
|
|
||||||
const colorKey = {
|
const colorKey = {
|
||||||
disabled: '#c6c6c6',
|
disabled: '#6A6E73',
|
||||||
healthy: '#50D050',
|
healthy: '#3E8635',
|
||||||
error: '#ff6766',
|
error: '#C9190B',
|
||||||
};
|
};
|
||||||
return colorKey[nodeState];
|
return colorKey[nodeState];
|
||||||
}
|
}
|
||||||
|
function renderLabelText(nodeState, name) {
|
||||||
|
const stateKey = {
|
||||||
|
disabled: '\u25EF',
|
||||||
|
healthy: '\u2713',
|
||||||
|
error: '\u0021',
|
||||||
|
};
|
||||||
|
return `${stateKey[nodeState]} ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderNodeColor(nodeType) {
|
function renderNodeType(nodeType) {
|
||||||
const colorKey = {
|
const typeKey = {
|
||||||
hop: '#C46100',
|
hop: 'h',
|
||||||
execution: '#F0AB00',
|
execution: 'Ex',
|
||||||
hybrid: '#0066CC',
|
hybrid: 'Hy',
|
||||||
control: '#005F60',
|
control: 'C',
|
||||||
};
|
};
|
||||||
|
|
||||||
return colorKey[nodeType];
|
return typeKey[nodeType];
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightSiblings(n) {
|
function highlightSiblings(n) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
svg.selectAll(`id-${n.id}`).attr('r', highlightRadius);
|
svg.select(`circle.id-${n.id}`).attr('fill', defaultNodeHighlightColor);
|
||||||
const immediate = graph.links.filter(
|
const immediate = graph.links.filter(
|
||||||
(l) =>
|
(l) =>
|
||||||
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
||||||
@@ -347,50 +302,91 @@ function MeshGraph({ redirectToDetailsPage }) {
|
|||||||
svg
|
svg
|
||||||
.selectAll(`.link-${s.index}`)
|
.selectAll(`.link-${s.index}`)
|
||||||
.transition()
|
.transition()
|
||||||
.style('stroke', '#6e6e6e');
|
.style('stroke', '#0066CC')
|
||||||
svg
|
.style('stroke-width', '3px');
|
||||||
.selectAll(`.id-${s.source.id}`)
|
|
||||||
.transition()
|
|
||||||
.attr('r', highlightRadius);
|
|
||||||
svg
|
|
||||||
.selectAll(`.id-${s.target.id}`)
|
|
||||||
.transition()
|
|
||||||
.attr('r', highlightRadius);
|
|
||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselectSiblings(n) {
|
function deselectSiblings(n) {
|
||||||
svg.selectAll(`id-${n.id}`).attr('r', defaultRadius);
|
svg.select(`circle.id-${n.id}`).attr('fill', defaultNodeColor);
|
||||||
const immediate = graph.links.filter(
|
const immediate = graph.links.filter(
|
||||||
(l) =>
|
(l) =>
|
||||||
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
n.hostname === l.source.hostname || n.hostname === l.target.hostname
|
||||||
);
|
);
|
||||||
immediate.forEach((s) => {
|
immediate.forEach((s) => {
|
||||||
svg.selectAll(`.link-${s.index}`).transition().style('stroke', '#ccc');
|
|
||||||
svg
|
svg
|
||||||
.selectAll(`.id-${s.source.id}`)
|
.selectAll(`.link-${s.index}`)
|
||||||
.transition()
|
.transition()
|
||||||
.attr('r', defaultRadius);
|
.style('stroke', '#ccc')
|
||||||
svg
|
.style('stroke-width', '2px');
|
||||||
.selectAll(`.id-${s.target.id}`)
|
|
||||||
.transition()
|
|
||||||
.attr('r', defaultRadius);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function redirectToDetailsPage() {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', debounce(handleResize, 500));
|
window.addEventListener('resize', debounce(handleResize, 500));
|
||||||
draw();
|
handleResize();
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [draw]);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return <div id="chart" />;
|
return (
|
||||||
|
<div id="chart" style={{ position: 'relative' }}>
|
||||||
|
<Legend />
|
||||||
|
<Tooltip
|
||||||
|
isNodeSelected={isNodeSelected}
|
||||||
|
renderNodeIcon={renderNodeIcon}
|
||||||
|
nodeDetail={nodeDetail}
|
||||||
|
redirectToDetailsPage={redirectToDetailsPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MeshGraph;
|
export default MeshGraph;
|
||||||
|
|||||||
107
awx/ui/src/screens/TopologyView/Tooltip.js
Normal file
107
awx/ui/src/screens/TopologyView/Tooltip.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/* eslint-disable i18next/no-literal-string,
|
||||||
|
jsx-a11y/anchor-is-valid,
|
||||||
|
jsx-a11y/click-events-have-key-events,
|
||||||
|
jsx-a11y/no-static-element-interactions */
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import {
|
||||||
|
Button as PFButton,
|
||||||
|
DescriptionList as PFDescriptionList,
|
||||||
|
DescriptionListTerm,
|
||||||
|
DescriptionListGroup as PFDescriptionListGroup,
|
||||||
|
DescriptionListDescription,
|
||||||
|
Divider,
|
||||||
|
TextContent,
|
||||||
|
Text as PFText,
|
||||||
|
TextVariants,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import StatusLabel from 'components/StatusLabel';
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
right: 0;
|
||||||
|
padding: 10px;
|
||||||
|
width: 20%;
|
||||||
|
`;
|
||||||
|
const Button = styled(PFButton)`
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
`;
|
||||||
|
const DescriptionList = styled(PFDescriptionList)`
|
||||||
|
gap: 0;
|
||||||
|
`;
|
||||||
|
const DescriptionListGroup = styled(PFDescriptionListGroup)`
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
const Text = styled(PFText)`
|
||||||
|
margin: 10px 0 5px;
|
||||||
|
`;
|
||||||
|
function Tooltip({
|
||||||
|
isNodeSelected,
|
||||||
|
renderNodeIcon,
|
||||||
|
nodeDetail,
|
||||||
|
redirectToDetailsPage,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Wrapper class="tooltip" data-cy="tooltip">
|
||||||
|
{isNodeSelected === false ? (
|
||||||
|
<TextContent>
|
||||||
|
<Text
|
||||||
|
component={TextVariants.small}
|
||||||
|
style={{ 'font-weight': 'bold', color: 'black' }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Text>
|
||||||
|
<Divider component="div" />
|
||||||
|
<Text component={TextVariants.small}>
|
||||||
|
Click on a node icon to display the details.
|
||||||
|
</Text>
|
||||||
|
</TextContent>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextContent>
|
||||||
|
<Text
|
||||||
|
component={TextVariants.small}
|
||||||
|
style={{ 'font-weight': 'bold', color: 'black' }}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</Text>
|
||||||
|
<Divider component="div" />
|
||||||
|
</TextContent>
|
||||||
|
<DescriptionList isHorizontal isFluid>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>
|
||||||
|
<Button variant="primary" isSmall>
|
||||||
|
{renderNodeIcon()}
|
||||||
|
</Button>
|
||||||
|
</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>
|
||||||
|
<a onClick={redirectToDetailsPage}>{nodeDetail.hostname}</a>
|
||||||
|
</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>Type</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>
|
||||||
|
{nodeDetail.node_type} node
|
||||||
|
</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
<DescriptionListGroup>
|
||||||
|
<DescriptionListTerm>Status</DescriptionListTerm>
|
||||||
|
<DescriptionListDescription>
|
||||||
|
<StatusLabel status={nodeDetail.node_state} />
|
||||||
|
</DescriptionListDescription>
|
||||||
|
</DescriptionListGroup>
|
||||||
|
</DescriptionList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import ScreenHeader from 'components/ScreenHeader/ScreenHeader';
|
import ScreenHeader from 'components/ScreenHeader/ScreenHeader';
|
||||||
import { PageSection, Card, CardBody } from '@patternfly/react-core';
|
import { PageSection, Card, CardBody } from '@patternfly/react-core';
|
||||||
import useRequest from 'hooks/useRequest';
|
import useRequest from 'hooks/useRequest';
|
||||||
import { MeshAPI, InstancesAPI } from 'api';
|
import { MeshAPI } from 'api';
|
||||||
import MeshGraph from './MeshGraph';
|
import MeshGraph from './MeshGraph';
|
||||||
|
|
||||||
function TopologyView() {
|
function TopologyView() {
|
||||||
const {
|
const {
|
||||||
|
isLoading,
|
||||||
result: { meshData },
|
result: { meshData },
|
||||||
// error: fetchInitialError,
|
// error: fetchInitialError,
|
||||||
request: fetchMeshVisualizer,
|
request: fetchMeshVisualizer,
|
||||||
@@ -22,15 +21,6 @@ function TopologyView() {
|
|||||||
}, []),
|
}, []),
|
||||||
{ meshData: { nodes: [], links: [] } }
|
{ 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(() => {
|
useEffect(() => {
|
||||||
fetchMeshVisualizer();
|
fetchMeshVisualizer();
|
||||||
}, [fetchMeshVisualizer]);
|
}, [fetchMeshVisualizer]);
|
||||||
@@ -40,14 +30,7 @@ function TopologyView() {
|
|||||||
|
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>{!isLoading && <MeshGraph data={meshData} />}</CardBody>
|
||||||
{meshData && (
|
|
||||||
<MeshGraph
|
|
||||||
data={meshData}
|
|
||||||
redirectToDetailsPage={RedirectToDetailsPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user