diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 1271c186..04619ce8 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -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, diff --git a/spikes/graphs-topology/d3/client/graph/graph-link.js b/spikes/graphs-topology/d3/client/graph/graph-link.js index fbcad8b1..e18a4ef8 100644 --- a/spikes/graphs-topology/d3/client/graph/graph-link.js +++ b/spikes/graphs-topology/d3/client/graph/graph-link.js @@ -1,4 +1,4 @@ -const React = require('React'); +const React = require('react'); const Styled = require('styled-components'); const { diff --git a/spikes/graphs-topology/d3/client/graph/graph-node-button.js b/spikes/graphs-topology/d3/client/graph/graph-node-button.js index bfef16c6..35227d39 100644 --- a/spikes/graphs-topology/d3/client/graph/graph-node-button.js +++ b/spikes/graphs-topology/d3/client/graph/graph-node-button.js @@ -1,4 +1,4 @@ -const React = require('React'); +const React = require('react'); const Styled = require('styled-components'); const { diff --git a/spikes/graphs-topology/d3/client/graph/graph-node-metrics.js b/spikes/graphs-topology/d3/client/graph/graph-node-metrics.js index 538d1874..ec1afb6a 100644 --- a/spikes/graphs-topology/d3/client/graph/graph-node-metrics.js +++ b/spikes/graphs-topology/d3/client/graph/graph-node-metrics.js @@ -1,4 +1,4 @@ -const React = require('React'); +const React = require('react'); const Styled = require('styled-components'); const GraphNodeButton = require('./graph-node-button'); diff --git a/spikes/graphs-topology/d3/client/graph/graph-node.js b/spikes/graphs-topology/d3/client/graph/graph-node.js index 0bbe9685..cb7d8e63 100644 --- a/spikes/graphs-topology/d3/client/graph/graph-node.js +++ b/spikes/graphs-topology/d3/client/graph/graph-node.js @@ -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'); diff --git a/spikes/graphs-topology/d3/client/topology.js b/spikes/graphs-topology/d3/client/topology.js index a4575255..c84979ec 100644 --- a/spikes/graphs-topology/d3/client/topology.js +++ b/spikes/graphs-topology/d3/client/topology.js @@ -7,11 +7,6 @@ const { default: styled } = Styled; -const StyledSvg = styled.svg` - width: 1024px; - height: 860px; -`; - const StyledForm = styled.form` margin: 20px; `; diff --git a/ui/.eslintrc b/ui/.eslintrc index 9f1ef695..4d27b6dd 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -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, diff --git a/ui/src/components/select/index.js b/ui/src/components/select/index.js index e4f514e5..475353d8 100644 --- a/ui/src/components/select/index.js +++ b/ui/src/components/select/index.js @@ -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 diff --git a/ui/src/components/topology-old/index.js b/ui/src/components/topology-old/index.js new file mode 100644 index 00000000..c4f9d7d4 --- /dev/null +++ b/ui/src/components/topology-old/index.js @@ -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 ( + + ); + } + +} + +TopologyGraph.propTypes = { + graph: React.PropTypes.object, + height: React.PropTypes.number, + width: React.PropTypes.number, +}; + +module.exports = TopologyGraph; diff --git a/ui/src/components/topology-old/story.js b/ui/src/components/topology-old/story.js new file mode 100644 index 00000000..f6710b78 --- /dev/null +++ b/ui/src/components/topology-old/story.js @@ -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', () => ( + + + + + + )); diff --git a/ui/src/components/topology/view.js b/ui/src/components/topology-old/view.js similarity index 100% rename from ui/src/components/topology/view.js rename to ui/src/components/topology-old/view.js diff --git a/spikes/graphs-topology/d3/client/data.js b/ui/src/components/topology/data.js similarity index 100% rename from spikes/graphs-topology/d3/client/data.js rename to ui/src/components/topology/data.js diff --git a/ui/src/components/topology/graph-link.js b/ui/src/components/topology/graph-link.js new file mode 100644 index 00000000..69c93646 --- /dev/null +++ b/ui/src/components/topology/graph-link.js @@ -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 ( + + + + + + + + + ); +}; + +GraphLink.propTypes = { + data: React.PropTypes.object.isRequired, + index: React.PropTypes.number.isRequired, + nodeSize: PropTypes.Size +}; + +module.exports = GraphLink; diff --git a/ui/src/components/topology/graph-node-button.js b/ui/src/components/topology/graph-node-button.js new file mode 100644 index 00000000..62679ed9 --- /dev/null +++ b/ui/src/components/topology/graph-node-button.js @@ -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) => ( + + )); + + return ( + + + {buttonCircles} + + ); +}; + +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; diff --git a/ui/src/components/topology/graph-node-info.js b/ui/src/components/topology/graph-node-info.js new file mode 100644 index 00000000..a715030e --- /dev/null +++ b/ui/src/components/topology/graph-node-info.js @@ -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 ( + + + {`${dcs} inst.`} + + {`${instances} DCs`} + + ); +}; + +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; diff --git a/ui/src/components/topology/graph-node-metrics.js b/ui/src/components/topology/graph-node-metrics.js new file mode 100644 index 00000000..42e032a3 --- /dev/null +++ b/ui/src/components/topology/graph-node-metrics.js @@ -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) => ( + + {`${metric.name}: ${metric.stat}`} + + )); + + return ( + + {metricsText} + + ); +}; + +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; diff --git a/ui/src/components/topology/graph-node.js b/ui/src/components/topology/graph-node.js new file mode 100644 index 00000000..30d4ee25 --- /dev/null +++ b/ui/src/components/topology/graph-node.js @@ -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 ( + + + + + + {data.id} + + + + + + + + ); +}; + +GraphNode.propTypes = { + data: React.PropTypes.object.isRequired, + size: PropTypes.Size, +}; + +module.exports = GraphNode; diff --git a/ui/src/components/topology/icon-data-centers.svg b/ui/src/components/topology/icon-data-centers.svg new file mode 100644 index 00000000..4df77b7a --- /dev/null +++ b/ui/src/components/topology/icon-data-centers.svg @@ -0,0 +1,20 @@ + + + + icon: data center + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/components/topology/icon-heart.svg b/ui/src/components/topology/icon-heart.svg new file mode 100644 index 00000000..3ff9b2a9 --- /dev/null +++ b/ui/src/components/topology/icon-heart.svg @@ -0,0 +1,18 @@ + + + + icon: health + Created with Sketch. + + + + + + + + + + + + + diff --git a/ui/src/components/topology/icon-instances.svg b/ui/src/components/topology/icon-instances.svg new file mode 100644 index 00000000..ec89bb7e --- /dev/null +++ b/ui/src/components/topology/icon-instances.svg @@ -0,0 +1,20 @@ + + + + icon: instances + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/components/topology/index.js b/ui/src/components/topology/index.js index c4f9d7d4..9cad64d2 100644 --- a/ui/src/components/topology/index.js +++ b/ui/src/components/topology/index.js @@ -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 ( - - ); - } - -} - -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; diff --git a/ui/src/components/topology/prop-types.js b/ui/src/components/topology/prop-types.js new file mode 100644 index 00000000..ce854981 --- /dev/null +++ b/ui/src/components/topology/prop-types.js @@ -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 +}; diff --git a/ui/src/components/topology/story-helper.js b/ui/src/components/topology/story-helper.js new file mode 100644 index 00000000..3775de36 --- /dev/null +++ b/ui/src/components/topology/story-helper.js @@ -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) => ( + + )); + }; + + 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 ( +
+ + + + + + + +
+ ); + } +} + +module.exports = StoryHelper; diff --git a/ui/src/components/topology/story.js b/ui/src/components/topology/story.js index f6710b78..0632f09f 100644 --- a/ui/src/components/topology/story.js +++ b/ui/src/components/topology/story.js @@ -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', () => ( - - - - - + )); diff --git a/ui/src/components/topology/topology-graph.js b/ui/src/components/topology/topology-graph.js new file mode 100644 index 00000000..ac51e96e --- /dev/null +++ b/ui/src/components/topology/topology-graph.js @@ -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) => ( + + )); + } + + renderLinks(nodeSize) { + return this.state.dataLinks.map((l, index) => ( + + )); + } + + render() { + const { + nodeSize + } = this.props; + + return ( + + + {this.renderNodes(nodeSize)} + + + {this.renderLinks(nodeSize)} + + + ); + } +} + +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;