Add new topology to ui components

This commit is contained in:
JUDIT GRESKOVITS 2017-02-07 18:26:38 +00:00
parent dc8954a110
commit 1f2f8a6965
25 changed files with 1379 additions and 580 deletions

View File

@ -68,7 +68,6 @@
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-target-blank": 2,
"react/jsx-pascal-case": 2,
"react/jsx-sort-props": 2,
"react/jsx-space-before-closing": 2,
"react/jsx-wrap-multilines": 2,
"jsx-a11y/anchor-has-content": 2,

View File

@ -1,4 +1,4 @@
const React = require('React');
const React = require('react');
const Styled = require('styled-components');
const {

View File

@ -1,4 +1,4 @@
const React = require('React');
const React = require('react');
const Styled = require('styled-components');
const {

View File

@ -1,4 +1,4 @@
const React = require('React');
const React = require('react');
const Styled = require('styled-components');
const GraphNodeButton = require('./graph-node-button');

View File

@ -1,4 +1,4 @@
const React = require('React');
const React = require('react');
const Styled = require('styled-components');
const GraphNodeButton = require('./graph-node-button');
const GraphNodeMetrics = require('./graph-node-metrics');

View File

@ -7,11 +7,6 @@ const {
default: styled
} = Styled;
const StyledSvg = styled.svg`
width: 1024px;
height: 860px;
`;
const StyledForm = styled.form`
margin: 20px;
`;

View File

@ -68,7 +68,6 @@
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-target-blank": 2,
"react/jsx-pascal-case": 2,
"react/jsx-sort-props": 2,
"react/jsx-space-before-closing": 2,
"react/jsx-wrap-multilines": 2,
"jsx-a11y/anchor-has-content": 2,

View File

@ -31,6 +31,7 @@ const Select = (props) => {
id = rndId(),
label = '',
multiple = false,
name = '',
placeholder = '',
value = defaultValue,
warning = ''
@ -67,6 +68,7 @@ const Select = (props) => {
disabled={disabled}
id={id}
multiple={multiple}
name={name}
placeholder={placeholder}
value={_placeholder ? value : undefined}
>
@ -84,6 +86,7 @@ Select.propTypes = {
id: React.PropTypes.string,
label: React.PropTypes.string,
multiple: React.PropTypes.bool,
name: React.PropTypes.string,
placeholder: React.PropTypes.string,
value: React.PropTypes.string,
warning: React.PropTypes.string

View File

@ -0,0 +1,425 @@
const constants = require('../../shared/constants');
const d3 = require('d3');
const fns = require('../../shared/functions');
const React = require('react');
const Styled = require('styled-components');
const {
colors
} = constants;
const {
remcalc
} = fns;
const {
default: styled
} = Styled;
/* eslint-disable */
function rightRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + y // Move to top left (absolute)
+ 'h ' + (width - 2 * radius) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius // Relative arc
+ 'v ' + (height - 2 * radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + radius // Relative arch
+ 'h ' + (2 * radius - width) // Horizontal lint to (relative)
+ 'z '; // path back to start
}
/* eslint-enable */
/* eslint-disable */
function leftRoundedRect(x, y, width, height, radius) {
return 'M' + (x + width) + ',' + y // Move to (absolute) start at top-right
+ 'v ' + height // Vertical line to (relative)
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + -radius // Relative arc
+ 'v ' + -(height - 2 * radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + -radius // Relative arch
+ 'z '; // path back to start
}
/* eslint-enable */
function topRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + -(y - height) // Move to (absolute) start at bottom-left
+ 'v ' + -(height - radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + -radius // Relative arc
+ 'h ' + -(2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius // Relative arc
+ 'v ' + (height - radius) // Vertical line to (relative)
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'z '; // path back to start
}
function bottomRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + -(y - (height - 2 * radius)) // Move to (absolute) start at bottom-right
+ 'v ' + -(height - 2 * radius) // Vertical line to (relative)
+ 'h ' + (width) // Horizontal line to (relative)
+ 'v ' + (height - 2 * radius) // Vertical line to (relative)
+ 'a ' + -radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + radius // Relative arc
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + -radius // Relative arc
+ 'z '; // path back to start
}
/* eslint-disable */
function rect(x, y, width, height) {
return 'M' + x + ',' + -(y - height) // Move to (absolute) start at bottom-right
+ 'v ' + -(height) // Vertical line to (relative)
+ 'h ' + width // Horizontal line to (relative)
+ 'v ' + height // Vertical line to (relative)
+ 'h ' + -(width) // Horizontal line to (relative)
+ 'z '; // path back to start
}
/* eslint-enable */
const StyledSVGContainer = styled.svg`
& {
.links line {
stroke: #343434;
stroke-opacity: 1;
}
.health, .health_warn {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: bold;
font-style: normal;
font-stretch: normal;
text-align: center;
}
.health_warn {
font-size: ${remcalc(15)};
}
.stat {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.node_statistics {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.node_statistics p {
margin: 0 0 0 0;
color: rgba(255, 255, 255, 0.8);
}
.primary, .secondary {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.info_text {
font-family: "LibreFranklin";
font-size: ${remcalc(16)};
font-weight: 600;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
}
`;
class TopologyGraph extends React.Component {
constructor(props) {
super(props);
this.svg = null;
const {
width,
height,
} = props;
this.simulation = d3.forceSimulation()
.force('charge', d3.forceManyBody()
.strength(() => -50)
.distanceMin(() => 30))
.force('link', d3.forceLink().distance(() => 200).id((d) => d.id))
// TODO manually handle looking for collisions in the tick, we then get the BBox
// and keep moving things for a while to try to get a fit.
.force('collide',
d3.forceCollide().radius((d) => 220 + 0.5).iterations(15))
.force('center', d3.forceCenter(width * 1/3, height * 1/3));
}
componentDidMount() {
const component = this;
const {
simulation,
} = this;
const svg = d3.select(this._refs.svg);
const {
width,
height,
graph = {
nodes: [],
links: []
},
} = this.props;
// Drawing the links between nodes
const link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(graph.links)
.enter().append('line')
.attr('stroke-width', remcalc(12));
// And svg group, to contain all of the attributes in @antonas' first prototype
svg.selectAll('.node')
.data(graph.nodes)
.enter()
.append('g')
.attr('class', 'node_group');
svg.selectAll('.node_group').each(function(d) {
// Create different type of node for services with Primaries + Secondaries
// We could extend this further to allow us to have as many nested services
// as wanted.
// TODO handle this per prop
// if (d.id === 'Percona') {
// createExtendedNode(d3.select(this));
// } else {
component.createServiceNodes(d, d3.select(this));
// }
});
simulation
.nodes(graph.nodes)
.on('tick', ticked);
simulation.force('link')
.links(graph.links);
function contrain(dimension, r, z) {
return Math.max(0, Math.min(dimension - r, z));
}
function ticked() {
// TODO: Think of a common way of extracting the bounding boxes for each
// item and to grab the x{1,2} and y{1,2} values.
link
.attr('x1', function(d) {
let x;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.source.index) return;
x = d3.select(this).node().getBBox().width;
});
return contrain(width, x, d.source.x) + 80;
})
.attr('y1', function(d) {
let y;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.source.index) return;
y = d3.select(this).node().getBBox().height;
});
return contrain(height, y, d.source.y) + 24;
})
.attr('x2', function(d) {
let x;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.target.index) return;
x = d3.select(this).node().getBBox().width;
});
return contrain(width, x, d.target.x) + 80;
})
.attr('y2', function(d) {
let y;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.target.index) return;
y = d3.select(this).node().getBBox().height;
});
return contrain(height, y, d.target.y) + 24;
});
svg.selectAll('.node_group')
.attr('transform', function(d) {
const x = d3.select(this).node().getBBox().width;
const y = d3.select(this).node().getBBox().height;
return 'translate(' + contrain(width, x, d.x) + ','
+ contrain(height, y, d.y) + ')';
});
}
}
createHealthCheckBadge(element, x, y) {
const paddingLeft = 30;
const health = element.append('g');
// TODO: replace these element with the designed SVG elements from
// @antonasdeduchovas' designs with full svg elements.
health.append('circle')
.attr('class', 'alert')
.attr('cx', function() {
return element
.node()
.getBBox()
.width + paddingLeft;
})
.attr('cy', '24')
.attr('stroke-width', 0)
.attr('fill', (d) =>
d.id === 'Memcached' ? 'rgb(217, 77, 68)' : 'rgb(0,175,102)')
.attr('r', remcalc(9));
// An icon or label that exists within the circle, inside the infobox
health.append('text')
.attr('class', 'health')
.attr('x', function() {
return element
.node()
.getBBox()
.width + 3;
})
.attr('y', '29')
.attr('text-anchor', 'middle')
.attr('fill', colors.brandPrimaryColor)
.text((d) => d.id === 'Memcached' ? '!' : '❤');
}
createServiceNodeBody(data, element, d) {
const stats = element.append('g');
stats.append('path')
.attr('class', 'node')
.attr('d', d)
.attr('stroke', '#343434')
.attr('stroke-width', remcalc(1))
.attr('fill', '#464646');
const html = stats
.append('switch')
.append('foreignObject')
.attr('requiredFeatures',
'http://www.w3.org/TR/SVG11/feature#Extensibility')
.attr('x', 12)
.attr('y', 57)
.attr('width', 160)
.attr('height', 70)
// From here everything will be rendered with react using a ref.
// However for now these values are hard-coded.
.append('xhtml:div')
.attr('class', 'node_statistics');
// Remove with react + dyanmic data.
html.selectAll('.node_statistics').data(data.metrics).enter()
.append('p')
.text((d) => `${d.name}: ${d.stat}`);
}
createServiceNodes(data, elm) {
const component = this;
const {
dragged,
dragstarted,
dragended,
} = this;
const width = 170;
const topHeight = 47;
const radius = 4;
// Box where label will live
elm.append('path')
.attr('class', 'node')
.attr('d', topRoundedRect('0', '0', width, topHeight, radius))
.attr('stroke', colors.topologyBackground)
.attr('stroke-width', remcalc(1))
.attr('fill', colors.brandSecondaryColor);
const text = elm.append('g');
text.append('text')
.attr('class', 'info_text')
.attr('x', '12')
.attr('y', '30')
.attr('text-anchor', 'start')
.attr('fill', colors.brandPrimaryColor)
.text(d => d.id);
// if (service is registered twice in the scheduler) {
// Do not show healthcheck in the header
// } else {
this.createHealthCheckBadge(text);
// }
// if (service is registered twice in the scheduler) {
// this.createServiceNodeBody(data, elm, rect('0',`-${topHeight}`, width, 78, 4));
// } else {
this.createServiceNodeBody(data, elm,
bottomRoundedRect('0', `-${topHeight}`, width, 78, 4));
// }
// <==== END ====>
// Set up movement for service nodes
elm.call(d3.drag()
.on('start', dragstarted.bind(component))
.on('drag', dragged.bind(component))
.on('end', dragended.bind(component)));
}
dragstarted(d) {
if (!d3.event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
dragended(d) {
if (!d3.event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
ref(name) {
this._refs = this._refs || {};
return (el) => {
this._refs[name] = el;
};
}
render() {
return (
<StyledSVGContainer
innerRef={this.ref('svg')}
{...this.props}
/>
);
}
}
TopologyGraph.propTypes = {
graph: React.PropTypes.object,
height: React.PropTypes.number,
width: React.PropTypes.number,
};
module.exports = TopologyGraph;

View File

@ -0,0 +1,154 @@
const React = require('react');
const {
storiesOf
} = require('@kadira/storybook');
const Base = require('../base');
const Topology = require('./');
const TopologyView = require('./view');
const services = {
nodes: [
{
id: 'Nginx',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'WordPress',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'Memcached',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'Percona',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'NFS',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
}
],
links: [
{
source: 'Nginx',
target: 'WordPress',
},
{
source: 'WordPress',
target: 'Memcached',
},
{
source: 'WordPress',
target: 'NFS',
},
{
source: 'WordPress',
target: 'Percona',
}
]
};
storiesOf('Topology', module)
.add('5 services', () => (
<Base>
<TopologyView>
<Topology
graph={services}
height={500}
width={500}
/>
</TopologyView>
</Base>
));

View File

@ -0,0 +1,154 @@
const React = require('react');
const Styled = require('styled-components');
const PropTypes = require('./prop-types');
const {
default: styled
} = Styled;
const StyledLine = styled.line`
stroke: #343434;
stroke-width: 1.5;
`;
const StyledCircle = styled.circle`
stroke: #343434;
fill: #464646;
stroke-width: 1.5;
`;
const StyledArrow = styled.line`
stroke: white;
stroke-width: 2;
stroke-linecap: round;
`;
const getAngleFromPoints = (source, target) => {
const lineAngle = Math.atan2(target.y-source.y, target.x - source.x);
const lineAngleDeg = lineAngle*180/Math.PI;
const zeroToThreeSixty = lineAngleDeg < 0 ? 360 + lineAngleDeg : lineAngleDeg;
return zeroToThreeSixty;
};
const getPosition = (angle, positions, position, noCorners=false) => {
const positionIndex = noCorners ?
Math.round(angle/90)*2 : Math.round(angle/45);
const offsetPosition = positions[positionIndex];
return {
id: offsetPosition.id,
x: position.x + offsetPosition.x,
y: position.y + offsetPosition.y
};
};
const getPositions = (halfWidth, halfHeight, halfCorner=0) => ([{
id: 'r',
x: halfWidth,
y: 0
}, {
id: 'br',
x: halfWidth - halfCorner,
y: halfHeight - halfCorner
}, {
id: 'b',
x: 0,
y: halfHeight
}, {
id: 'bl',
x: -halfWidth + halfCorner,
y: halfHeight - halfCorner
}, {
id: 'l',
x: -halfWidth,
y: 0
}, {
id: 'tl',
x: -halfWidth + halfCorner,
y: -halfHeight + halfCorner
}, {
id: 't',
x: 0,
y: -halfHeight
}, {
id: 'tr',
x: halfWidth - halfCorner,
y: -halfHeight + halfCorner
},{
id: 'r',
x: halfWidth,
y: 0
}]);
const GraphLink = ({
data,
index,
nodeSize
}) => {
const {
source,
target
} = data;
// actually, this will need to be got dynamically, in case them things are different sizes
const {
width,
height
} = nodeSize;
const halfWidth = width/2;
const halfHeight = height/2;
const halfCorner = 2;
const positions = getPositions(halfWidth, halfHeight, halfCorner);
const sourceAngle = getAngleFromPoints(source, target);
const sourcePosition = getPosition(sourceAngle, positions, source);
const targetAngle = getAngleFromPoints(target, source);
const targetPosition = getPosition(targetAngle, positions, target, true);
const arrowAngle = getAngleFromPoints(sourcePosition, targetPosition);
return (
<g>
<StyledLine
x1={sourcePosition.x}
x2={targetPosition.x}
y1={sourcePosition.y}
y2={targetPosition.y}
/>
<g
transform={
// eslint-disable-next-line max-len
`translate(${targetPosition.x}, ${targetPosition.y}) rotate(${arrowAngle})`
}
>
<StyledCircle
cx={0}
cy={0}
r={9}
/>
<StyledArrow
x1={-1}
x2={2}
y1={-3}
y2={0}
/>
<StyledArrow
x1={-1}
x2={2}
y1={3}
y2={0}
/>
</g>
</g>
);
};
GraphLink.propTypes = {
data: React.PropTypes.object.isRequired,
index: React.PropTypes.number.isRequired,
nodeSize: PropTypes.Size
};
module.exports = GraphLink;

View File

@ -0,0 +1,57 @@
const React = require('react');
const Styled = require('styled-components');
const {
default: styled
} = Styled;
const StyledButton = styled.rect`
opacity: 0;
cursor: pointer;
`;
const StyledButtonCircle = styled.circle`
fill: white;
`;
const GraphNodeButton = ({
buttonRect,
onButtonClick
}) => {
const buttonCircleRadius = 2;
const buttonCircleSpacing = 2;
const buttonCircleY =
(buttonRect.height - buttonCircleRadius*4 - buttonCircleSpacing*2)/2;
const buttonCircles = [1,2,3].map((item, index) => (
<StyledButtonCircle
cx={buttonRect.width/2}
cy={buttonCircleY + (buttonCircleRadius*2 + buttonCircleSpacing)*index}
key={index}
r={2}
/>
));
return (
<g transform={`translate(${buttonRect.x}, ${buttonRect.y})`}>
<StyledButton
height={buttonRect.height}
onClick={onButtonClick}
width={buttonRect.width}
/>
{buttonCircles}
</g>
);
};
GraphNodeButton.propTypes = {
buttonRect: React.PropTypes.shape({
x: React.PropTypes.number,
y: React.PropTypes.number,
width: React.PropTypes.number,
height: React.PropTypes.number
}).isRequired,
onButtonClick: React.PropTypes.func.isRequired
};
module.exports = GraphNodeButton;

View File

@ -0,0 +1,55 @@
const React = require('react');
const Styled = require('styled-components');
const DataCentresIcon =
require(
// eslint-disable-next-line max-len
'!babel-loader!svg-react-loader!./icon-data-centers.svg?name=DataCentresIcon'
);
const InstancesIcon =
require(
'!babel-loader!svg-react-loader!./icon-instances.svg?name=InstancesIcon'
);
const {
default: styled
} = Styled;
const StyledText = styled.text`
fill: white;
font-size: 12px;
opacity: 0.8;
`;
const GraphNodeInfo = ({
attrs,
infoPosition
}) => {
const {
dcs,
instances
} = attrs;
return (
<g transform={`translate(${infoPosition.x}, ${infoPosition.y})`}>
<DataCentresIcon />
<StyledText>{`${dcs} inst.`}</StyledText>
<InstancesIcon />
<StyledText>{`${instances} DCs`}</StyledText>
</g>
);
};
GraphNodeInfo.propTypes = {
attrs: React.PropTypes.shape({
dcs: React.PropTypes.number,
instances: React.PropTypes.number,
healthy: React.PropTypes.bool
}),
infoPosition: React.PropTypes.shape({
x: React.PropTypes.number,
y: React.PropTypes.number
})
};
module.exports = GraphNodeInfo;

View File

@ -0,0 +1,48 @@
const React = require('react');
const Styled = require('styled-components');
const {
default: styled
} = Styled;
const StyledText = styled.text`
fill: white;
font-size: 12px;
opacity: 0.8;
`;
const GraphNodeMetrics = ({
metrics,
metricsPosition
}) => {
const metricSpacing = 18;
const metricsText = metrics.map((metric, index) => (
<StyledText
key={index}
x={0}
y={12 + metricSpacing*index}
>
{`${metric.name}: ${metric.stat}`}
</StyledText>
));
return (
<g transform={`translate(${metricsPosition.x}, ${metricsPosition.y})`}>
{metricsText}
</g>
);
};
GraphNodeMetrics.propTypes = {
metrics: React.PropTypes.arrayOf(React.PropTypes.shape({
name: React.PropTypes.string,
stat: React.PropTypes.string
})),
metricsPosition: React.PropTypes.shape({
x: React.PropTypes.number,
y: React.PropTypes.number
})
};
module.exports = GraphNodeMetrics;

View File

@ -0,0 +1,130 @@
const React = require('react');
const Styled = require('styled-components');
const PropTypes = require('./prop-types');
const GraphNodeButton = require('./graph-node-button');
const GraphNodeMetrics = require('./graph-node-metrics');
const HeartIcon =
require(
'!babel-loader!svg-react-loader!./icon-heart.svg?name=HeartIcon'
);
const {
default: styled
} = Styled;
const StyledRect = styled.rect`
stroke: #343434;
fill: #464646;
stroke-width: 1.5;
rx: 4;
ry: 4;
`;
const StyledShadowRect = styled.rect`
fill: #464646;
opacity: 0.33;
rx: 4;
ry: 4;
`;
const StyledLine = styled.line`
stroke: #343434;
stroke-width: 1.5;
`;
const StyledText = styled.text`
fill: white;
font-size: 16px;
font-weight: 600;
`;
const HeartCircle = styled.circle`
fill: #00af66;
`;
const GraphNode = ({
data,
size
}) => {
const {
width,
height
} = size;
const halfWidth = width/2;
const halfHeight = height/2;
const lineY = 48 - halfHeight;
const lineX = 140 - halfWidth;
const buttonRect = {
x: lineX,
y: -halfHeight,
width: width - 140,
height: 48
};
const onButtonClick = (evt) => {
// console.log('Rect clicked!!!');
};
const paddingLeft = 18-halfWidth;
const metricsPosition = {
x: paddingLeft,
y: 89 - halfHeight
};
// const titleBbBox = {x:100, y: 30 - halfHeight};
return (
<g transform={`translate(${data.x}, ${data.y})`}>
<StyledShadowRect
x={-halfWidth}
y={3-halfHeight}
width={width}
height={height}
/>
<StyledRect
x={-halfWidth}
y={-halfHeight}
width={width}
height={height}
/>
<StyledLine
x1={-halfWidth}
y1={lineY}
x2={halfWidth}
y2={lineY}
/>
<StyledLine
x1={lineX}
y1={-halfHeight}
x2={lineX}
y2={lineY}
/>
<StyledText x={paddingLeft} y={30 - halfHeight}>{data.id}</StyledText>
<g transform={`translate(${25}, ${15 - halfHeight})`}>
<HeartCircle
cx={9}
cy={9}
r={9}
/>
<HeartIcon />
</g>
<GraphNodeButton
buttonRect={buttonRect}
onButtonClick={onButtonClick}
/>
<GraphNodeMetrics
metrics={data.metrics}
metricsPosition={metricsPosition}
/>
</g>
);
};
GraphNode.propTypes = {
data: React.PropTypes.object.isRequired,
size: PropTypes.Size,
};
module.exports = GraphNode;

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="9px" height="13px" viewBox="0 0 9 13" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
<title>icon: data center</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Project:-topology-of-services" transform="translate(-575.000000, -487.000000)" fill="#FFFFFF">
<g id="services-copy" transform="translate(213.000000, 426.000000)">
<g id="service:-nginx" transform="translate(263.000000, 0.000000)">
<g id="metric">
<g id="data-centers-&amp;-instanecs" transform="translate(18.000000, 59.000000)">
<path d="M81,15 L90,15 L90,2 L81,2 L81,15 Z M83,13 L88,13 L88,4 L83,4 L83,13 Z M84,6 L87.001,6 L87.001,5 L84,5 L84,6 Z M84,8 L87.001,8 L87.001,7 L84,7 L84,8 Z M84,10 L87.001,10 L87.001,9 L84,9 L84,10 Z" id="icon:--data-center"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="80px" height="70px" viewBox="0 0 80 70" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
<title>icon: health</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Project:-topology-of-services" transform="translate(0, 0)" fill="#FFFFFF">
<g id="services-copy" transform="translate(0, 0)">
<g id="service:-nginx" transform="translate(0, 0)">
<g id="icon:-state" transform="translate(0, 0)">
<path d="M9.47745233,6.60270759 L8.95496861,7.04565311 L8.51133742,6.60270759 C7.70841297,5.79909747 6.40563205,5.79909747 5.60270759,6.60270759 C4.79909747,7.40631772 4.79909747,8.70841297 5.60270759,9.5120231 L8.95496861,12.8642841 L12.3668833,9.5120231 C13.1698077,8.70841297 13.2301471,7.40631772 12.4265369,6.60270759 C11.6229268,5.79909747 10.2810625,5.79909747 9.47745233,6.60270759 Z" id="icon:-health"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="9px" viewBox="0 0 18 9" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 42 (36781) - http://www.bohemiancoding.com/sketch -->
<title>icon: instances</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Project:-topology-of-services" transform="translate(-494.000000, -489.000000)" fill="#FFFFFF">
<g id="services-copy" transform="translate(213.000000, 426.000000)">
<g id="service:-nginx" transform="translate(263.000000, 0.000000)">
<g id="metric">
<g id="data-centers-&amp;-instanecs" transform="translate(18.000000, 59.000000)">
<path d="M4.5,4 C2.015,4 0,6.015 0,8.5 C0,10.985 2.015,13 4.5,13 C6.985,13 9,10.985 9,8.5 C9,6.015 6.985,4 4.5,4 M13.0909091,4 C12.7145455,4 12.3512727,4.047 12,4.12 C14.184,4.576 15.8181818,6.359 15.8181818,8.5 C15.8181818,10.641 14.184,12.424 12,12.88 C12.3512727,12.953 12.7145455,13 13.0909091,13 C15.8018182,13 18,10.985 18,8.5 C18,6.015 15.8018182,4 13.0909091,4 M14,8.5 C14,10.985 11.8018182,13 9.09090909,13 C8.71454545,13 8.35127273,12.953 8,12.88 C10.184,12.424 11.8181818,10.641 11.8181818,8.5 C11.8181818,6.359 10.184,4.576 8,4.12 C8.35127273,4.047 8.71454545,4 9.09090909,4 C11.8018182,4 14,6.015 14,8.5" id="icon:-instances"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,425 +1,7 @@
const constants = require('../../shared/constants');
const d3 = require('d3');
const fns = require('../../shared/functions');
const React = require('react');
const Styled = require('styled-components');
const {
colors
} = constants;
const {
remcalc
} = fns;
const {
default: styled
} = Styled;
/* eslint-disable */
function rightRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + y // Move to top left (absolute)
+ 'h ' + (width - 2 * radius) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius // Relative arc
+ 'v ' + (height - 2 * radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + radius // Relative arch
+ 'h ' + (2 * radius - width) // Horizontal lint to (relative)
+ 'z '; // path back to start
}
/* eslint-enable */
/* eslint-disable */
function leftRoundedRect(x, y, width, height, radius) {
return 'M' + (x + width) + ',' + y // Move to (absolute) start at top-right
+ 'v ' + height // Vertical line to (relative)
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + -radius // Relative arc
+ 'v ' + -(height - 2 * radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + -radius // Relative arch
+ 'z '; // path back to start
}
/* eslint-enable */
function topRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + -(y - height) // Move to (absolute) start at bottom-left
+ 'v ' + -(height - radius) // Vertical line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + -radius // Relative arc
+ 'h ' + -(2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + radius + ',' + radius // Relative arc
+ 'v ' + (height - radius) // Vertical line to (relative)
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'z '; // path back to start
}
function bottomRoundedRect(x, y, width, height, radius) {
return 'M' + x + ',' + -(y - (height - 2 * radius)) // Move to (absolute) start at bottom-right
+ 'v ' + -(height - 2 * radius) // Vertical line to (relative)
+ 'h ' + (width) // Horizontal line to (relative)
+ 'v ' + (height - 2 * radius) // Vertical line to (relative)
+ 'a ' + -radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + radius // Relative arc
+ 'h ' + (2 * radius - width) // Horizontal line to (relative)
+ 'a ' + radius + ',' + radius + ' 0 0 1 ' + -radius + ',' + -radius // Relative arc
+ 'z '; // path back to start
}
/* eslint-disable */
function rect(x, y, width, height) {
return 'M' + x + ',' + -(y - height) // Move to (absolute) start at bottom-right
+ 'v ' + -(height) // Vertical line to (relative)
+ 'h ' + width // Horizontal line to (relative)
+ 'v ' + height // Vertical line to (relative)
+ 'h ' + -(width) // Horizontal line to (relative)
+ 'z '; // path back to start
}
/* eslint-enable */
const StyledSVGContainer = styled.svg`
& {
.links line {
stroke: #343434;
stroke-opacity: 1;
}
.health, .health_warn {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: bold;
font-style: normal;
font-stretch: normal;
text-align: center;
}
.health_warn {
font-size: ${remcalc(15)};
}
.stat {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.node_statistics {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.node_statistics p {
margin: 0 0 0 0;
color: rgba(255, 255, 255, 0.8);
}
.primary, .secondary {
font-family: "LibreFranklin";
font-size: ${remcalc(12)};
font-weight: normal;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
.info_text {
font-family: "LibreFranklin";
font-size: ${remcalc(16)};
font-weight: 600;
font-style: normal;
font-stretch: normal;
line-height: 1.5;
}
}
`;
class TopologyGraph extends React.Component {
constructor(props) {
super(props);
this.svg = null;
const {
width,
height,
} = props;
this.simulation = d3.forceSimulation()
.force('charge', d3.forceManyBody()
.strength(() => -50)
.distanceMin(() => 30))
.force('link', d3.forceLink().distance(() => 200).id((d) => d.id))
// TODO manually handle looking for collisions in the tick, we then get the BBox
// and keep moving things for a while to try to get a fit.
.force('collide',
d3.forceCollide().radius((d) => 220 + 0.5).iterations(15))
.force('center', d3.forceCenter(width * 1/3, height * 1/3));
}
componentDidMount() {
const component = this;
const {
simulation,
} = this;
const svg = d3.select(this._refs.svg);
const {
width,
height,
graph = {
nodes: [],
links: []
},
} = this.props;
// Drawing the links between nodes
const link = svg.append('g')
.attr('class', 'links')
.selectAll('line')
.data(graph.links)
.enter().append('line')
.attr('stroke-width', remcalc(12));
// And svg group, to contain all of the attributes in @antonas' first prototype
svg.selectAll('.node')
.data(graph.nodes)
.enter()
.append('g')
.attr('class', 'node_group');
svg.selectAll('.node_group').each(function(d) {
// Create different type of node for services with Primaries + Secondaries
// We could extend this further to allow us to have as many nested services
// as wanted.
// TODO handle this per prop
// if (d.id === 'Percona') {
// createExtendedNode(d3.select(this));
// } else {
component.createServiceNodes(d, d3.select(this));
// }
});
simulation
.nodes(graph.nodes)
.on('tick', ticked);
simulation.force('link')
.links(graph.links);
function contrain(dimension, r, z) {
return Math.max(0, Math.min(dimension - r, z));
}
function ticked() {
// TODO: Think of a common way of extracting the bounding boxes for each
// item and to grab the x{1,2} and y{1,2} values.
link
.attr('x1', function(d) {
let x;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.source.index) return;
x = d3.select(this).node().getBBox().width;
});
return contrain(width, x, d.source.x) + 80;
})
.attr('y1', function(d) {
let y;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.source.index) return;
y = d3.select(this).node().getBBox().height;
});
return contrain(height, y, d.source.y) + 24;
})
.attr('x2', function(d) {
let x;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.target.index) return;
x = d3.select(this).node().getBBox().width;
});
return contrain(width, x, d.target.x) + 80;
})
.attr('y2', function(d) {
let y;
svg.selectAll('.node_group').each(function(_, i) {
if (i !== d.target.index) return;
y = d3.select(this).node().getBBox().height;
});
return contrain(height, y, d.target.y) + 24;
});
svg.selectAll('.node_group')
.attr('transform', function(d) {
const x = d3.select(this).node().getBBox().width;
const y = d3.select(this).node().getBBox().height;
return 'translate(' + contrain(width, x, d.x) + ','
+ contrain(height, y, d.y) + ')';
});
}
}
createHealthCheckBadge(element, x, y) {
const paddingLeft = 30;
const health = element.append('g');
// TODO: replace these element with the designed SVG elements from
// @antonasdeduchovas' designs with full svg elements.
health.append('circle')
.attr('class', 'alert')
.attr('cx', function() {
return element
.node()
.getBBox()
.width + paddingLeft;
})
.attr('cy', '24')
.attr('stroke-width', 0)
.attr('fill', (d) =>
d.id === 'Memcached' ? 'rgb(217, 77, 68)' : 'rgb(0,175,102)')
.attr('r', remcalc(9));
// An icon or label that exists within the circle, inside the infobox
health.append('text')
.attr('class', 'health')
.attr('x', function() {
return element
.node()
.getBBox()
.width + 3;
})
.attr('y', '29')
.attr('text-anchor', 'middle')
.attr('fill', colors.brandPrimaryColor)
.text((d) => d.id === 'Memcached' ? '!' : '❤');
}
createServiceNodeBody(data, element, d) {
const stats = element.append('g');
stats.append('path')
.attr('class', 'node')
.attr('d', d)
.attr('stroke', '#343434')
.attr('stroke-width', remcalc(1))
.attr('fill', '#464646');
const html = stats
.append('switch')
.append('foreignObject')
.attr('requiredFeatures',
'http://www.w3.org/TR/SVG11/feature#Extensibility')
.attr('x', 12)
.attr('y', 57)
.attr('width', 160)
.attr('height', 70)
// From here everything will be rendered with react using a ref.
// However for now these values are hard-coded.
.append('xhtml:div')
.attr('class', 'node_statistics');
// Remove with react + dyanmic data.
html.selectAll('.node_statistics').data(data.metrics).enter()
.append('p')
.text((d) => `${d.name}: ${d.stat}`);
}
createServiceNodes(data, elm) {
const component = this;
const {
dragged,
dragstarted,
dragended,
} = this;
const width = 170;
const topHeight = 47;
const radius = 4;
// Box where label will live
elm.append('path')
.attr('class', 'node')
.attr('d', topRoundedRect('0', '0', width, topHeight, radius))
.attr('stroke', colors.topologyBackground)
.attr('stroke-width', remcalc(1))
.attr('fill', colors.brandSecondaryColor);
const text = elm.append('g');
text.append('text')
.attr('class', 'info_text')
.attr('x', '12')
.attr('y', '30')
.attr('text-anchor', 'start')
.attr('fill', colors.brandPrimaryColor)
.text(d => d.id);
// if (service is registered twice in the scheduler) {
// Do not show healthcheck in the header
// } else {
this.createHealthCheckBadge(text);
// }
// if (service is registered twice in the scheduler) {
// this.createServiceNodeBody(data, elm, rect('0',`-${topHeight}`, width, 78, 4));
// } else {
this.createServiceNodeBody(data, elm,
bottomRoundedRect('0', `-${topHeight}`, width, 78, 4));
// }
// <==== END ====>
// Set up movement for service nodes
elm.call(d3.drag()
.on('start', dragstarted.bind(component))
.on('drag', dragged.bind(component))
.on('end', dragended.bind(component)));
}
dragstarted(d) {
if (!d3.event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
dragged(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
dragended(d) {
if (!d3.event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
ref(name) {
this._refs = this._refs || {};
return (el) => {
this._refs[name] = el;
};
}
render() {
return (
<StyledSVGContainer
innerRef={this.ref('svg')}
{...this.props}
/>
);
}
}
TopologyGraph.propTypes = {
graph: React.PropTypes.object,
height: React.PropTypes.number,
width: React.PropTypes.number,
module.exports = {
TopologyGraph: require('./topology-graph'),
TopologyGraphNode: require('./graph-node'),
TopologyGraphLink: require('./graph-link'),
TopologyGraphNodeButton: require('./graph-node-button'),
TopologyGraphNodeMetrics: require('./graph-node-metrics'),
};
module.exports = TopologyGraph;

View File

@ -0,0 +1,30 @@
const React = require('react');
const p = {
x: React.PropTypes.number.isRequired,
y: React.PropTypes.number.isRequired
};
const s = {
width: React.PropTypes.number,
height: React.PropTypes.number
};
const Point = React.PropTypes.shape({
...p
});
const Size = React.PropTypes.shape({
...s
});
const Rect = React.PropTypes.shape({
...p,
...s
});
module.exports = {
Point,
Rect,
Size
};

View File

@ -0,0 +1,109 @@
const React = require('react');
const Styled = require('styled-components');
const Input = require('../input');
const Select = require('../select');
const Topology = require('./');
const data = require('./data');
const {
default: styled
} = Styled;
const {
TopologyGraph
} = Topology;
const StyledForm = styled.form`
display: flex;
flex-direction: row;
margin: 5px;
`;
const nodeSize = {
width: 180,
height: 156
};
class StoryHelper extends React.Component {
constructor(props){
super(props);
this.state = {
data: data
};
}
render() {
const data = this.state.data;
const linkOptions = (nodes) => {
return nodes.map((node, index) => (
<option key={node.id} value={node.id}>{node.id}</option>
));
};
const onAddService = (evt) => {
evt.preventDefault();
const service = evt.target.service.value;
const target = evt.target.target.value;
const source = evt.target.source.value;
const links = [];
if(target) {
links.push({
target: target,
source: service
});
}
if(source) {
links.push({
target: service,
source: source
});
}
if(links.length) {
const data = this.state.data;
this.setState({
data: {
nodes: [
...data.nodes,
{
...data.nodes[0],
id: service
}
],
links: [
...data.links,
...links
]
}
});
}
};
return (
<div>
<StyledForm onSubmit={onAddService}>
<Input name='service' placeholder='Service name' />
<Select name='target'>
<option value=''>Select a service to link to (optional)</option>
{linkOptions(data.nodes)}
</Select>
<Select name='source'>
<option value=''>Select a service to link from (optional)</option>
{linkOptions(data.nodes)}
</Select>
<Input name='Add service' type='submit' />
</StyledForm>
<TopologyGraph data={data} nodeSize={nodeSize} />
</div>
);
}
}
module.exports = StoryHelper;

View File

@ -1,154 +1,11 @@
const React = require('react');
const StoryHelper = require('./story-helper');
const {
storiesOf
} = require('@kadira/storybook');
const Base = require('../base');
const Topology = require('./');
const TopologyView = require('./view');
const services = {
nodes: [
{
id: 'Nginx',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'WordPress',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'Memcached',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'Percona',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
},
{
id: 'NFS',
attrs: {
dcs: 1,
instances: 2,
healthy: true,
},
metrics: [
{
name: 'CPU',
stat: '50%',
},
{
name: 'Memory',
stat: '20%',
},
{
name: 'Network',
stat: '5.9KB/sec',
},
]
}
],
links: [
{
source: 'Nginx',
target: 'WordPress',
},
{
source: 'WordPress',
target: 'Memcached',
},
{
source: 'WordPress',
target: 'NFS',
},
{
source: 'WordPress',
target: 'Percona',
}
]
};
storiesOf('Topology', module)
.add('5 services', () => (
<Base>
<TopologyView>
<Topology
graph={services}
height={500}
width={500}
/>
</TopologyView>
</Base>
<StoryHelper />
));

View File

@ -0,0 +1,144 @@
const React = require('react');
const d3 = require('d3');
const Styled = require('styled-components');
const GraphNode = require('./graph-node');
const GraphLink = require('./graph-link');
const {
default: styled
} = Styled;
const StyledSvg = styled.svg`
width: 1024px;
height: 860px;
border: 1px solid #ff0000;
`;
/*const nodeSize = {
width: 180,
height: 156
};*/
const mapData = (data, withIndex=false) => {
return data.map((d, index) => {
const r = {
...d
};
if(withIndex) {
r.index = index;
}
return r;
});
};
class TopologyGraph extends React.Component {
componentWillMount() {
const {
data,
nodeSize
} = this.props;
this.setState(
this.createSimulation(data, nodeSize)
);
}
componentWillReceiveProps(nextProps) {
const {
data,
nodeSize
} = nextProps;
this.setState(
this.createSimulation(data, nodeSize)
);
}
createSimulation(data, nodeSize) {
const dataNodes = mapData(data.nodes, true);
const dataLinks = mapData(data.links);
const {
width,
height
} = nodeSize;
const nodeRadius = Math.round(Math.sqrt(width*width + height*height)/2);
// const linkDistance = nodeRadius*2 + 20;
// console.log('nodeRadius = ', nodeRadius);
// console.log('linkDistance = ', linkDistance);
const simulation = d3.forceSimulation(dataNodes)
.force('charge', d3.forceManyBody())
.force('link', d3.forceLink(dataLinks)
/*.distance(() => linkDistance)*/
.id(d => d.id))
.force('collide', d3.forceCollide(nodeRadius))
.force('center', d3.forceCenter(1024/2, 860/2))
.on('tick', () => {
this.forceUpdate();
})
.on('end', () => {
// console.log('SIMULATION END');
});
return {
dataNodes,
dataLinks,
simulation
};
}
renderNodes(nodeSize) {
return this.state.dataNodes.map((n, index) => (
<GraphNode
key={index}
data={n}
index={index}
size={nodeSize}
/>
));
}
renderLinks(nodeSize) {
return this.state.dataLinks.map((l, index) => (
<GraphLink
key={index}
data={l}
index={index}
nodeSize={nodeSize}
/>
));
}
render() {
const {
nodeSize
} = this.props;
return (
<StyledSvg>
<g>
{this.renderNodes(nodeSize)}
</g>
<g>
{this.renderLinks(nodeSize)}
</g>
</StyledSvg>
);
}
}
TopologyGraph.propTypes = {
data: React.PropTypes.shape({
nodes: React.PropTypes.array,
links: React.PropTypes.array
}),
nodeSize: React.PropTypes.shape({
width: React.PropTypes.number,
height: React.PropTypes.number
})
};
module.exports = TopologyGraph;