415 lines
12 KiB
JavaScript
415 lines
12 KiB
JavaScript
import React from 'react';
|
|
import { Svg } from 'normalized-styled-components';
|
|
import PropTypes from 'prop-types';
|
|
import difference from 'lodash.difference';
|
|
import differenceBy from 'lodash.differenceby';
|
|
|
|
import Baseline from '../baseline';
|
|
import Constants from './constants';
|
|
import { createSimulation } from './simulation';
|
|
import TopologyNode from './node';
|
|
import TopologyLink from './link';
|
|
import TopologyLinkArrow from './link/arrow';
|
|
import { getNodeRect, calculateLineLayout } from './functions';
|
|
|
|
const StyledSvg = Svg.extend`
|
|
width: 100%;
|
|
height: 1400px;
|
|
`;
|
|
|
|
/**
|
|
* @example ./usage.md
|
|
*/
|
|
class Topology extends React.Component {
|
|
componentWillMount() {
|
|
this.create(this.props);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.boundResize = this.handleResize.bind(this);
|
|
window.addEventListener('resize', this.boundResize);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
window.removeEventListener('resize', this.boundResize);
|
|
}
|
|
|
|
shouldComponentUpdate() {
|
|
return false;
|
|
}
|
|
|
|
getChangedConnections(services, nextServices) {
|
|
return nextServices.reduce((changed, nextService) => {
|
|
if(changed.added || changed.removed) {
|
|
return changed;
|
|
}
|
|
const service = services.filter(service => service.id === nextService.id).shift();
|
|
const connectionsAdded = difference(nextService.connections, service.connections).length;
|
|
// there's a new connection, we need to redraw
|
|
if(connectionsAdded) {
|
|
return ({ added: true });
|
|
}
|
|
const connectionsRemoved = difference(service.connections, nextService.connections).length;
|
|
// we'll need to remove the offending connections from links
|
|
if(connectionsRemoved) {
|
|
return ({ removed: true});
|
|
}
|
|
return changed;
|
|
}, {});
|
|
}
|
|
|
|
getNextLinks(nextServices) {
|
|
const links = this.state.links;
|
|
return links.reduce((nextLinks, link) => {
|
|
const sourceExists = nextServices.filter(nextService =>
|
|
nextService.id === link.source.id);
|
|
if(sourceExists.length) {
|
|
const source = sourceExists.shift();
|
|
const targetExists = nextServices.filter(nextService =>
|
|
nextService.id === link.target.id).length;
|
|
const connectionExists = source.connections.filter(connection =>
|
|
connection === link.target.id).length;
|
|
if(targetExists && connectionExists) {
|
|
nextLinks.push(link);
|
|
}
|
|
}
|
|
return nextLinks;
|
|
}, []);
|
|
}
|
|
|
|
getNextNodes(nextServices) {
|
|
const nodes = this.state.nodes;
|
|
return nodes.reduce((nextNodes, node) => {
|
|
const keep = nextServices.filter(nextService => nextService.id === node.id).length;
|
|
if(keep) {
|
|
nextNodes.push(node);
|
|
}
|
|
return nextNodes;
|
|
}, []);
|
|
}
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
// if we remove a node, it should just be removed from the simulation nodes and links
|
|
// if we add a node, then we should recreate the damn thing
|
|
// on other updates, we should update the services on the state and that's it
|
|
// we should forceUpdate once the state has been updated
|
|
const nextServices = nextProps.services.sort();
|
|
const { services, nodes } = this.state;
|
|
if(nextServices.length > services.length) {
|
|
// new service added, we need to redraw
|
|
this.create(nextProps);
|
|
}
|
|
else if(nextServices.length <= services.length) {
|
|
|
|
const servicesRemoved = differenceBy(services, nextServices, 'id');
|
|
const servicesChanged = differenceBy(nextServices, services, 'id');
|
|
if(servicesChanged.length ||
|
|
servicesRemoved.length !== services.length - nextServices.length) {
|
|
this.create(nextProps);
|
|
}
|
|
else {
|
|
// check whether there are new connections. if so, we need to redraw
|
|
// if we just dropped one, we need to remove it from links
|
|
// comparison to yield 3 possible outcomes; no change, added, dropped
|
|
const changedConnections = this.getChangedConnections(services, nextServices);
|
|
// if connections are added, we'll need to redraw
|
|
if(changedConnections.added) {
|
|
this.create(nextProps);
|
|
}
|
|
else if(servicesRemoved.length || changedConnections.removed) {
|
|
const nextNodes = servicesRemoved.length
|
|
? this.getNextNodes(nextServices)
|
|
: nodes;
|
|
const nextLinks = this.getNextLinks(nextServices);
|
|
this.setState({
|
|
services: nextServices,
|
|
links: nextLinks,
|
|
nodes: nextNodes,
|
|
}, () => this.forceUpdate());
|
|
}
|
|
else {
|
|
// we've got the same services, no links changed, so we just need to set them to the state
|
|
this.setState({ services: nextServices }, () => this.forceUpdate());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
handleResize(evt) {
|
|
this.create(this.props);
|
|
// resize should just rejig the positions
|
|
}
|
|
|
|
create(props) {
|
|
// other updates should also just update the services rather than recreate the simulation
|
|
const services = props.services.sort();
|
|
const connectedServices = services.filter(service => service.connected);
|
|
const notConnectedServices = services.filter(service => !service.connected && !service.isConsul);
|
|
const svgSize = this.getSvgSize();
|
|
|
|
const { nodes, links, simulation } = createSimulation(services, svgSize);
|
|
|
|
this.setState({
|
|
nodes,
|
|
links,
|
|
simulation,
|
|
services
|
|
}, () => {
|
|
this.forceUpdate();
|
|
});
|
|
}
|
|
|
|
getSvgSize() {
|
|
if (document.getElementById('topology-svg')) {
|
|
return document.getElementById('topology-svg').getBoundingClientRect();
|
|
}
|
|
|
|
const windowWidth =
|
|
window.innerWidth ||
|
|
document.documentElement.clientWidth ||
|
|
document.body.clientWidth;
|
|
|
|
return {
|
|
width: windowWidth - 2 * 24,
|
|
height: 1400
|
|
};
|
|
}
|
|
|
|
constrainNodePosition(x, y, children = false) {
|
|
const svgSize = this.getSvgSize();
|
|
|
|
const nodeRect = children
|
|
? Constants.nodeRectWithChildren
|
|
: Constants.nodeRect;
|
|
|
|
if (x < nodeRect.right + 2) {
|
|
x = nodeRect.right + 2;
|
|
} else if (x > svgSize.width + nodeRect.left - 2) {
|
|
x = svgSize.width + nodeRect.left - 2;
|
|
}
|
|
|
|
if (y < -nodeRect.top + 2) {
|
|
y = -nodeRect.top + 2;
|
|
} else if (y > svgSize.height - nodeRect.bottom - 2) {
|
|
y = svgSize.height - nodeRect.bottom - 2;
|
|
}
|
|
|
|
return {
|
|
x,
|
|
y
|
|
};
|
|
}
|
|
|
|
findNode(nodeId) {
|
|
return this.state.nodes.reduce(
|
|
(acc, simNode, index) => (simNode.id === nodeId ? simNode : acc),
|
|
{}
|
|
);
|
|
}
|
|
|
|
getConsulNodePosition() {
|
|
const svgSize = this.getSvgSize();
|
|
const x = svgSize.width - Constants.nodeSize.width;
|
|
|
|
return {
|
|
x,
|
|
y: 0
|
|
};
|
|
}
|
|
|
|
getConstrainedNodePosition(nodeId, children = false) {
|
|
const node = this.findNode(nodeId);
|
|
return this.constrainNodePosition(node.x, node.y, children);
|
|
}
|
|
|
|
findNodeData(nodesData, nodeId) {
|
|
return nodesData.filter(nodeData => nodeData.id === nodeId).shift();
|
|
}
|
|
|
|
setDragInfo(dragging, nodeId = null, position = {}) {
|
|
this.dragInfo = {
|
|
dragging,
|
|
nodeId,
|
|
position
|
|
};
|
|
}
|
|
|
|
render() {
|
|
const { onQuickActionsClick, onNodeTitleClick } = this.props;
|
|
|
|
const { nodes, links, services } = this.state;
|
|
|
|
const nodesData = services.map((service, index) => {
|
|
const nodePosition = service.isConsul
|
|
? this.getConsulNodePosition()
|
|
: this.getConstrainedNodePosition(service.id, service.children);
|
|
|
|
const nodeRect = getNodeRect(service);
|
|
|
|
return {
|
|
...service,
|
|
...nodePosition,
|
|
nodeRect
|
|
};
|
|
});
|
|
|
|
// TODO links will need to know whether a service has children
|
|
// if it does, the height of it will be different
|
|
const t = links
|
|
.map((link, index) => ({
|
|
source: this.findNodeData(nodesData, link.source.id),
|
|
target: this.findNodeData(nodesData, link.target.id)
|
|
}));
|
|
const linksData = t
|
|
.map((linkData, index) => {
|
|
return calculateLineLayout(linkData, index)
|
|
});
|
|
|
|
const onDragStart = (evt, nodeId) => {
|
|
// It's this node's position that we'll need to update
|
|
|
|
const x = evt.changedTouches ? evt.changedTouches[0].pageX : evt.clientX;
|
|
const y = evt.changedTouches ? evt.changedTouches[0].pageY : evt.clientY;
|
|
|
|
this.setDragInfo(true, nodeId, {
|
|
x,
|
|
y
|
|
});
|
|
};
|
|
|
|
const onDragMove = evt => {
|
|
if (this.dragInfo && this.dragInfo.dragging) {
|
|
const x = evt.changedTouches
|
|
? evt.changedTouches[0].pageX
|
|
: evt.clientX;
|
|
const y = evt.changedTouches
|
|
? evt.changedTouches[0].pageY
|
|
: evt.clientY;
|
|
|
|
const offset = {
|
|
x: x - this.dragInfo.position.x,
|
|
y: y - this.dragInfo.position.y
|
|
};
|
|
|
|
const dragNodes = nodes.map((simNode, index) => {
|
|
if (simNode.id === this.dragInfo.nodeId) {
|
|
return {
|
|
...simNode,
|
|
x: simNode.x + offset.x,
|
|
y: simNode.y + offset.y
|
|
};
|
|
}
|
|
return {
|
|
...simNode
|
|
};
|
|
});
|
|
|
|
this.setState({
|
|
nodes: dragNodes
|
|
}, () => this.forceUpdate());
|
|
|
|
this.setDragInfo(true, this.dragInfo.nodeId, {
|
|
x,
|
|
y
|
|
});
|
|
}
|
|
};
|
|
|
|
const onDragEnd = evt => {
|
|
this.setDragInfo(false);
|
|
};
|
|
|
|
const renderedNode = (n, index) =>
|
|
<TopologyNode
|
|
key={index}
|
|
data={n}
|
|
index={index}
|
|
onDragStart={onDragStart}
|
|
onNodeTitleClick={onNodeTitleClick}
|
|
onQuickActions={onQuickActionsClick}
|
|
/>;
|
|
|
|
const renderedLink = (l, index) =>
|
|
<TopologyLink key={index} data={l} index={index} />;
|
|
|
|
const renderedLinkArrow = (l, index) =>
|
|
<TopologyLinkArrow key={index} data={l} index={index} />;
|
|
|
|
const renderedNodes = this.dragInfo && this.dragInfo.dragging
|
|
? nodesData
|
|
.filter((n, index) => n.id !== this.dragInfo.nodeId)
|
|
.map((n, index) => renderedNode(n, index))
|
|
: nodesData.map((n, index) => renderedNode(n, index));
|
|
|
|
const renderedLinks = linksData.map((l, index) => renderedLink(l, index));
|
|
|
|
const renderedLinkArrows = this.dragInfo && this.dragInfo.dragging
|
|
? linksData
|
|
.filter((l, index) => l.target.id !== this.dragInfo.nodeId)
|
|
.map((l, index) => renderedLinkArrow(l, index))
|
|
: linksData.map((l, index) => renderedLinkArrow(l, index));
|
|
|
|
const dragNode = !this.dragInfo || !this.dragInfo.dragging
|
|
? null
|
|
: renderedNode(
|
|
nodesData.reduce((dragNode, n, index) => {
|
|
if (n.id === this.dragInfo.nodeId) {
|
|
return n;
|
|
}
|
|
return dragNode;
|
|
}, {})
|
|
);
|
|
|
|
const dragLinkArrow = !this.dragInfo ||
|
|
!this.dragInfo.dragging ||
|
|
renderedLinkArrows.length === renderedLinks.length
|
|
? null
|
|
: renderedLinkArrow(
|
|
linksData.reduce((dragLinkArrow, l, index) => {
|
|
if (l.target.id === this.dragInfo.nodeId) {
|
|
return l;
|
|
}
|
|
return dragLinkArrow;
|
|
}, {})
|
|
);
|
|
|
|
return (
|
|
<StyledSvg
|
|
onMouseMove={onDragMove}
|
|
onTouchMove={onDragMove}
|
|
onMouseUp={onDragEnd}
|
|
onTouchEnd={onDragEnd}
|
|
onTouchCancel={onDragEnd}
|
|
id="topology-svg"
|
|
>
|
|
<g>
|
|
{renderedNodes}
|
|
</g>
|
|
<g>
|
|
{renderedLinks}
|
|
</g>
|
|
<g>
|
|
{renderedLinkArrows}
|
|
</g>
|
|
<g>
|
|
{dragNode}
|
|
</g>
|
|
<g>
|
|
{dragLinkArrow}
|
|
</g>
|
|
</StyledSvg>
|
|
);
|
|
}
|
|
}
|
|
|
|
Topology.propTypes = {
|
|
onQuickActionsClick: PropTypes.func,
|
|
onNodeTitleClick: PropTypes.func,
|
|
services: PropTypes.array
|
|
};
|
|
|
|
export default Baseline(Topology);
|
|
|
|
export { default as TopologyNode } from './node';
|
|
export { default as TopologyLink } from './link';
|