├── __mocks__ ├── styleMock.js └── fileMock.js ├── public ├── index.js ├── favicon.ico ├── manifest.json ├── index.html └── logo.svg ├── .stylelintrc ├── .eslintrc ├── .babelrc ├── .travis.yml ├── .gitignore ├── src ├── link │ ├── shapes.js │ ├── index.js │ └── arrow.js ├── Readme.md ├── node │ ├── metrics.js │ ├── title.js │ ├── content.js │ ├── button.js │ ├── info.js │ ├── shapes.js │ └── index.js ├── prop-types.js ├── constants.js ├── simulation.js ├── data │ └── index.js ├── __tests__ │ └── Topology.spec.js ├── functions.js └── index.js ├── CONTRIBUTING.md ├── styleguide.config.js ├── README.md └── package.json /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./Button.js"; 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-joyent-portal"] 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yldio/react-topology/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "joyent-portal", 3 | "rules": { 4 | "jsx-a11y/href-no-hash": 0 5 | } 6 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": "joyent-portal", 3 | "env": { 4 | "production": { 5 | "plugins": [ 6 | "transform-react-remove-prop-types" 7 | ] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | node_js: 4 | - "8" 5 | - "7" 6 | before_install: yarn global add greenkeeper-lockfile@1 7 | before_script: greenkeeper-lockfile-update 8 | after_script: greenkeeper-lockfile-upload -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /styleguide 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /src/link/shapes.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { theme } from 'joyent-ui-toolkit'; 3 | 4 | export const GraphLinkLine = styled.line` 5 | stroke: #c0c0c0; 6 | stroke-width: 1.5; 7 | `; 8 | 9 | export const GraphLinkCircle = styled.circle` 10 | stroke: #343434; 11 | fill: ${theme.secondary}; 12 | stroke-width: 1.5; 13 | `; 14 | 15 | export const GraphLinkArrowLine = styled.line` 16 | stroke: ${theme.white}; 17 | stroke-width: 2; 18 | stroke-linecap: round; 19 | `; 20 | -------------------------------------------------------------------------------- /src/link/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { GraphLinkLine } from './shapes'; 4 | 5 | const GraphLink = ({ data, index }) => { 6 | const { sourcePosition, targetPosition } = data; 7 | 8 | return ( 9 | 15 | ); 16 | }; 17 | 18 | GraphLink.propTypes = { 19 | data: PropTypes.object.isRequired, 20 | index: PropTypes.number 21 | }; 22 | 23 | export default GraphLink; 24 | -------------------------------------------------------------------------------- /src/Readme.md: -------------------------------------------------------------------------------- 1 | ``` 2 | const data = require('./data/index.js'); 3 | 4 | ``` 5 | 6 | ### A single instance: 7 | 8 | ``` 9 | const data = { 10 | id: 'frontend-app', 11 | name: 'Frontend', 12 | status: 'active', 13 | connections: ['graphql-server'], 14 | instanceStatuses: [ 15 | { 16 | status: 'running', 17 | count: 1 18 | }, 19 | { 20 | status: 'failed', 21 | count: 1 22 | } 23 | ], 24 | instancesActive: true, 25 | instancesHealthy: { 26 | total: 2, 27 | healthy: 0 28 | }, 29 | transitionalStatus: false, 30 | reversed: true 31 | }; 32 | 33 | 34 | ``` 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/link/arrow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { GraphLinkCircle, GraphLinkArrowLine } from './shapes'; 4 | 5 | const GraphLinkArrow = ({ data, index }) => { 6 | const { targetPosition, arrowAngle } = data; 7 | 8 | return ( 9 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | GraphLinkArrow.propTypes = { 21 | data: PropTypes.object.isRequired, 22 | index: PropTypes.number 23 | }; 24 | 25 | export default GraphLinkArrow; 26 | -------------------------------------------------------------------------------- /src/node/metrics.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Point } from '../prop-types'; 3 | import { GraphText } from './shapes'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const GraphNodeMetrics = ({ connected, metrics, pos }) => { 7 | const { x, y } = pos; 8 | 9 | const metricSpacing = 18; 10 | const metricsText = metrics.map((metric, index) => ( 11 | 17 | {`${metric.name}: ${metric.value}`} 18 | 19 | )); 20 | 21 | return {metricsText}; 22 | }; 23 | 24 | GraphNodeMetrics.propTypes = { 25 | connected: PropTypes.bool, 26 | metrics: PropTypes.arrayOf( 27 | PropTypes.shape({ 28 | name: PropTypes.string.isRequired, 29 | value: PropTypes.string.isRequired 30 | }) 31 | ), 32 | pos: Point.isRequired 33 | }; 34 | 35 | export default GraphNodeMetrics; 36 | -------------------------------------------------------------------------------- /src/node/title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Constants from '../constants'; 4 | import { GraphTitle } from './shapes'; 5 | 6 | const GraphNodeTitle = ({ 7 | data, 8 | onTitleClick, 9 | primaryColor, 10 | secondaryColor 11 | }) => ( 12 | 13 | 24 | {data.name} 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | GraphNodeTitle.propTypes = { 33 | data: PropTypes.object.isRequired, 34 | onTitleClick: PropTypes.func 35 | }; 36 | 37 | export default GraphNodeTitle; 38 | -------------------------------------------------------------------------------- /src/prop-types.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const p = { 4 | x: PropTypes.number.isRequired, 5 | y: PropTypes.number.isRequired 6 | }; 7 | 8 | const s = { 9 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 10 | height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) 11 | }; 12 | 13 | export const Point = PropTypes.shape({ 14 | ...p 15 | }); 16 | 17 | export const Size = PropTypes.shape({ 18 | ...s 19 | }); 20 | 21 | export const Rect = PropTypes.shape({ 22 | ...p, 23 | ...s 24 | }); 25 | 26 | const statuses = ['active', 'running', 'failed', 'unknown']; 27 | 28 | export const instanceStatuses = PropTypes.shape({ 29 | count: PropTypes.number, 30 | status: PropTypes.oneOf(statuses), 31 | healthy: PropTypes.string 32 | }); 33 | 34 | export const instances = PropTypes.shape({ 35 | id: PropTypes.string.isRequired, 36 | status: PropTypes.oneOf(statuses), 37 | healthy: PropTypes.string 38 | }); 39 | 40 | export const instancesHealthy = PropTypes.shape({ 41 | total: PropTypes.number, 42 | healthy: PropTypes.number 43 | }); 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development Workflow 2 | 3 | ### Small Feature Development 4 | 5 | Contributors who have write access to the repository will practice continuous 6 | delivery (CD as known from now on in this document). 7 | 8 | We will define CD in this document as a method of developing a feature per 9 | commit with an encapsulating test that proves that the functionality is working, 10 | the contributor will test their code locally and if all is passing will push to 11 | *master*. 12 | 13 | For contributors that do not have write access, follow the same conventions but 14 | open a Pull Request instead. 15 | 16 | ### Large changesets 17 | 18 | When larger changes need to be made, or the work that is carried out spans 19 | multiple components / services of the application at the same time a single 20 | commit will not suffice. 21 | 22 | In this scenario, the contributor should open a pull request instead. 23 | 24 | ## Commit messages 25 | 26 | Follow [Git blessed](http://chris.beams.io/posts/git-commit/) and [Conventional 27 | Commits](https://conventionalcommits.org) 28 | 29 | 1. Separate subject from body with a blank line 30 | 1. Limit the subject line to 50 characters 31 | 1. Capitalize the subject line 32 | 1. Do not end the subject line with a period 33 | 1. Use the imperative mood in the subject line 34 | 1. Wrap the body at 72 characters 35 | 1. Use the body to explain what and why vs. how 36 | 37 | Types: 38 | 39 | - build 40 | - chore 41 | - ci 42 | - docs 43 | - feat 44 | - fix 45 | - perf 46 | - refactor 47 | - revert 48 | - style 49 | - test 50 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('react-scripts/config/webpack.config.dev.js'); 2 | const { defaultHandlers } = require('react-docgen'); 3 | const dnHandler = require('react-docgen-displayname-handler'); 4 | const path = require('path'); 5 | 6 | module.exports = { 7 | sections: [ 8 | { 9 | content: 'README.md' 10 | }, 11 | { 12 | name: 'React Topology', 13 | components: './src/index.js' 14 | }, 15 | ], 16 | defaultExample: true, 17 | title: 'React Topology', 18 | showUsage: true, 19 | showSidebar: false, 20 | webpackConfig: Object.assign(webpackConfig, { 21 | module: Object.assign(webpackConfig.module, { 22 | rules: [ 23 | { 24 | test: /\.svg$/, 25 | loader: 'svg-inline-loader' 26 | }, 27 | { 28 | test: /\.css$/, 29 | use: ['style-loader', 'css-loader'] 30 | }, 31 | { 32 | test: /\.(js|jsx)$/, 33 | use: ['babel-loader'] 34 | }, 35 | { 36 | test: /\.(eot|ttf|woff|woff2)$/, 37 | use: [ 38 | { 39 | loader: 'file-loader' 40 | } 41 | ] 42 | }, 43 | { 44 | test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/], 45 | loader: 'url-loader', 46 | options: { 47 | limit: 10000, 48 | name: 'static/media/[name].[hash:8].[ext]' 49 | } 50 | } 51 | ] 52 | }) 53 | }), 54 | handlers: componentPath => 55 | defaultHandlers.concat(dnHandler.createDisplayNameHandler(componentPath)) 56 | }; 57 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/node/content.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Constants from '../constants'; 4 | import { GraphLine, GraphSubtitle } from './shapes'; 5 | import GraphNodeInfo from './info'; 6 | 7 | const GraphNodeContent = ({ 8 | child = false, 9 | data, 10 | y = Constants.contentRect.y, 11 | index = 0, 12 | primaryColor, 13 | secondaryColor 14 | }) => { 15 | const { x, width } = Constants.contentRect; 16 | const reverse = data.isConsul || data.reversed; 17 | 18 | const nodeInfoPos = child 19 | ? { 20 | x: Constants.infoPosition.x, 21 | y: Constants.infoPosition.y + 21 22 | } 23 | : Constants.infoPosition; 24 | 25 | const nodeSubtitle = child ? ( 26 | 33 | {data.name} 34 | 35 | ) : null; 36 | 37 | const nodeInfo = ( 38 | 44 | ); 45 | return ( 46 | 47 | 57 | {nodeSubtitle} 58 | {nodeInfo} 59 | 60 | ); 61 | }; 62 | 63 | GraphNodeContent.propTypes = { 64 | child: PropTypes.bool, 65 | data: PropTypes.object.isRequired, 66 | index: PropTypes.number 67 | }; 68 | 69 | export default GraphNodeContent; 70 | -------------------------------------------------------------------------------- /src/node/button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Constants from '../constants'; 4 | import { GraphLine, GraphButtonRect, GraphButtonCircle } from './shapes'; 5 | 6 | const NodeButton = ({ 7 | onButtonClick, 8 | index, 9 | isConsul, 10 | reversed, 11 | instancesActive, 12 | primaryColor, 13 | secondaryColor 14 | }) => { 15 | const { x, y, width, height } = Constants.buttonRect; 16 | const reverse = isConsul || reversed; 17 | 18 | const buttonCircleRadius = 2; 19 | const buttonCircleSpacing = 2; 20 | const buttonCircleY = 21 | (height - buttonCircleRadius * 4 - buttonCircleSpacing * 2) / 2; 22 | 23 | const buttonCircles = [1, 2, 3].map((item, index) => ( 24 | 36 | )); 37 | 38 | return ( 39 | 40 | 50 | {buttonCircles} 51 | 60 | 61 | ); 62 | }; 63 | 64 | NodeButton.propTypes = { 65 | index: PropTypes.number.isRequired, 66 | onButtonClick: PropTypes.func.isRequired, 67 | isConsul: PropTypes.bool, 68 | reversed: PropTypes.bool, 69 | instancesActive: PropTypes.bool 70 | }; 71 | 72 | export default NodeButton; 73 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const Lengths = { 2 | paddingLeft: 12, 3 | nodeWidth: 180, 4 | statusHeight: 18 5 | }; 6 | 7 | const Sizes = { 8 | buttonSize: { 9 | width: 40, 10 | height: 48 11 | }, 12 | contentSize: { 13 | width: Lengths.nodeWidth, 14 | // height: 101 // This is the height w/o info comp 15 | height: 42 16 | }, 17 | childContentSize: { 18 | width: Lengths.nodeWidth, 19 | height: 60 20 | }, 21 | nodeSize: { 22 | width: Lengths.nodeWidth, 23 | // height: 156 24 | height: 90 25 | }, 26 | nodeSizeWithChildren: { 27 | width: Lengths.nodeWidth, 28 | // height: 276 29 | height: 176 30 | } 31 | }; 32 | 33 | const Points = { 34 | buttonPosition: { 35 | x: Lengths.nodeWidth - Sizes.buttonSize.width, 36 | y: 0 37 | }, 38 | contentPosition: { 39 | x: 0, 40 | y: Sizes.buttonSize.height 41 | }, 42 | infoPosition: { 43 | x: Lengths.paddingLeft, 44 | y: 11 45 | }, 46 | metricsPosition: { 47 | x: Lengths.paddingLeft, 48 | y: 41 49 | }, 50 | subtitlePosition: { 51 | x: Lengths.paddingLeft, 52 | y: 23 53 | } 54 | }; 55 | 56 | const Rects = { 57 | // X, y, width, height 58 | buttonRect: { 59 | ...Sizes.buttonSize, 60 | ...Points.buttonPosition 61 | }, 62 | contentRect: { 63 | ...Sizes.contentSize, 64 | ...Points.contentPosition 65 | }, 66 | childContentRect: { 67 | ...Sizes.childContentSize, 68 | ...Points.contentPosition 69 | }, 70 | // Top, bottom, left, right - from 'centre' 71 | nodeRect: { 72 | ...Sizes.nodeSize, 73 | left: -Sizes.nodeSize.width / 2, 74 | right: Sizes.nodeSize.width / 2, 75 | top: -Sizes.nodeSize.height / 2, 76 | bottom: Sizes.nodeSize.height / 2 77 | }, 78 | nodeRectWithChildren: { 79 | ...Sizes.nodeSizeWithChildren, 80 | left: -Sizes.nodeSizeWithChildren.width / 2, 81 | right: Sizes.nodeSizeWithChildren.width / 2, 82 | top: -Sizes.nodeSizeWithChildren.height / 2 + Sizes.contentSize.height / 3, 83 | bottom: Sizes.nodeSizeWithChildren.height / 2 + Sizes.contentSize.height / 3 84 | } 85 | }; 86 | 87 | const Constants = { 88 | ...Lengths, 89 | ...Sizes, 90 | ...Points, 91 | ...Rects 92 | }; 93 | 94 | export default Constants; 95 | -------------------------------------------------------------------------------- /src/simulation.js: -------------------------------------------------------------------------------- 1 | import { forceSimulation, forceLink, forceCollide, forceCenter } from 'd3'; 2 | import Constants from './constants'; 3 | 4 | const hypotenuse = (a, b) => Math.sqrt(a * a + b * b); 5 | 6 | const rectRadius = ({ width, height }) => 7 | Math.round(hypotenuse(width, height) / 2); 8 | 9 | const forcePlayAnimation = (simulation, animationTicks) => { 10 | const n = 11 | Math.ceil( 12 | Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()) 13 | ) + 100; // - animationTicks; 14 | 15 | for (let i = 0; i < n; ++i) { 16 | simulation.tick(); 17 | } 18 | }; 19 | 20 | const createLinks = services => 21 | services.reduce( 22 | (acc, service, index) => 23 | service.connections 24 | ? acc.concat( 25 | service.connections.reduce((connections, connection, index) => { 26 | const targetExists = services.filter( 27 | service => service.id === connection 28 | ).length; 29 | if (targetExists) { 30 | connections.push({ 31 | source: service.id, 32 | target: connection 33 | }); 34 | } 35 | return connections; 36 | }, []) 37 | ) 38 | : acc, 39 | [] 40 | ); 41 | 42 | const createSimulation = (services, svgSize, animationTicks = 0) => { 43 | // This is not going to work given that as well as the d3 layout stuff, other things might be at play too 44 | // We should pass two objects to the components - one for positioning and one for data 45 | const nodes = services.map((service, index) => { 46 | return { 47 | id: service.id, 48 | index 49 | }; 50 | }); 51 | 52 | const links = createLinks(services); 53 | 54 | const { width, height } = svgSize; 55 | 56 | const nodeRadius = rectRadius(Constants.nodeSizeWithChildren); 57 | 58 | const simulation = forceSimulation(nodes) 59 | .force('link', forceLink(links).id(d => d.id)) 60 | .force('collide', forceCollide(nodeRadius)) 61 | .force('center', forceCenter(width / 2, height / 2)); 62 | 63 | forcePlayAnimation(simulation, animationTicks); 64 | 65 | return { 66 | nodes, 67 | links, 68 | simulation 69 | }; 70 | }; 71 | 72 | export default createSimulation; 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![react-topology logo](https://i.imgur.com/T1uJKdx.png) 2 | 3 | [![Build Status](https://travis-ci.org/yldio/react-topology.svg?branch=master)](https://travis-ci.org/yldio/react-topology) 4 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 5 | 6 | > topology is the arrangement of the various elements (links, nodes, etc.) of a communication network. 7 | 8 | React Topology allows you to create complicated network topologies in a very simple manner. 9 | 10 | ### Install 11 | 12 | ```bash static 13 | npm install react-topology 14 | or 15 | yarn add react-topology 16 | ``` 17 | 18 | ### Use 19 | 20 | ```js static 21 | import Topology from 'react-topology' 22 | const services = [ 23 | { 24 | id: 'frontend-app', 25 | name: 'Frontend', 26 | status: 'active', 27 | connections: ['graphql-server'], 28 | nodes: [ 29 | { 30 | status: 'running', 31 | count: 1 32 | }, 33 | { 34 | status: 'failed', 35 | count: 1 36 | } 37 | ], 38 | instancesActive: true, 39 | instancesHealthy: { 40 | total: 2, 41 | healthy: 0 42 | }, 43 | transitionalStatus: false, 44 | reversed: true 45 | }, 46 | { 47 | id: 'graphql-server', 48 | name: 'GraphQL', 49 | status: 'active', 50 | connections: ['api-server'], 51 | nodes: [ 52 | { 53 | status: 'running', 54 | count: 2 55 | } 56 | ], 57 | instancesActive: true, 58 | instancesHealthy: { 59 | total: 2, 60 | healthy: 2 61 | }, 62 | transitionalStatus: false, 63 | reversed: true 64 | }, 65 | { 66 | id: 'api-server', 67 | name: 'API', 68 | status: 'active', 69 | connections: ['graphql-server'], 70 | nodes: [ 71 | { 72 | status: 'running', 73 | count: 1 74 | }, 75 | { 76 | status: 'failed', 77 | count: 1 78 | }, 79 | { 80 | status: 'unknown', 81 | count: 1 82 | } 83 | ], 84 | instancesActive: true, 85 | instancesHealthy: { 86 | total: 3, 87 | healthy: 2 88 | }, 89 | transitionalStatus: false, 90 | reversed: false 91 | } 92 | ]; 93 | 94 | const Network = () => 95 | 96 | 97 | 98 | export default Network; 99 | ``` 100 | 101 | ### Contribute 102 | 103 | We're delighted that you'd like to contribute to the toolkit, as we're always looking for ways to improve it. 104 | 105 | If there is anything that you'd like to improve or propose, please submit a pull request. And remember to check the contribution [guidelines](CONTRIBUTING.md)!. 106 | 107 | #### Start 108 | 109 | ```bash static 110 | git clone git@github.com:yldio/react-topology.git 111 | cd react-topology 112 | yarn 113 | yarn start 114 | ``` 115 | 116 | ### License 117 | 118 | [MPL-2.0](LICENSE) 119 | 120 | Icon: Network by Brennan Novak from the Noun Project 121 | -------------------------------------------------------------------------------- /src/node/info.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import is, { isNot } from 'styled-is'; 4 | import PropTypes from 'prop-types'; 5 | import { Point } from '../prop-types'; 6 | import { GraphText } from './shapes'; 7 | import { 8 | HealthyIcon, 9 | theme, 10 | InstancesIcon, 11 | InstancesIconLight 12 | } from 'joyent-ui-toolkit'; 13 | 14 | const StyledInstancesIcon = styled(InstancesIcon)` 15 | fill: ${theme.secondary}; 16 | 17 | ${isNot('active')` 18 | fill: ${theme.secondary}; 19 | `}; 20 | `; 21 | 22 | const StyledHealthyIcon = styled(HealthyIcon)` 23 | fill: ${theme.orange}; 24 | 25 | ${is('healthy')` 26 | fill: ${theme.green}; 27 | `}; 28 | 29 | ${isNot('healthy')` 30 | fill: ${theme.orange}; 31 | `}; 32 | `; 33 | 34 | const GraphNodeInfo = ({ data, pos, primaryColor, secondaryColor }) => { 35 | const { 36 | instances, 37 | instanceStatuses, 38 | instancesHealthy, 39 | instancesActive, 40 | transitionalStatus, 41 | status, 42 | isConsul, 43 | reversed 44 | } = data; 45 | const reverse = isConsul || reversed; 46 | 47 | const { x, y } = pos; 48 | 49 | const statuses = transitionalStatus ? ( 50 | 56 | {status.toLowerCase()} 57 | 58 | ) : ( 59 | (instanceStatuses || []).map((instanceStatus, index) => ( 60 | 68 | {`${instanceStatus.count} 69 | ${instanceStatus.status.toLowerCase()}`} 70 | 71 | )) 72 | ); 73 | 74 | const healthy = ( 75 | 80 | ); 81 | 82 | return ( 83 | 84 | {healthy} 85 | 86 | {reverse ? ( 87 | 88 | ) : ( 89 | 90 | )} 91 | 92 | 100 | {`${(instances || instanceStatuses || []).length} inst.`} 101 | 102 | 103 | {statuses} 104 | 105 | 106 | ); 107 | }; 108 | 109 | GraphNodeInfo.propTypes = { 110 | data: PropTypes.object.isRequired, 111 | pos: Point.isRequired 112 | }; 113 | 114 | export default GraphNodeInfo; 115 | -------------------------------------------------------------------------------- /src/node/shapes.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import is, { isNot } from 'styled-is'; 3 | import { theme } from 'joyent-ui-toolkit'; 4 | import { darken, lighten } from 'polished'; 5 | 6 | export const GraphLine = styled.line` 7 | stroke: ${props => props.primaryColor && lighten(0.1, props.primaryColor)}; 8 | 9 | ${is('consul')` 10 | stroke: ${props => 11 | props.secondaryColor && darken(0.2, props.secondaryColor)}; 12 | `}; 13 | 14 | ${isNot('active')` 15 | stroke: ${props => 16 | props.secondaryColor && darken(0.2, props.secondaryColor)}; 17 | `}; 18 | `; 19 | 20 | export const GraphNodeRect = styled.rect` 21 | fill: ${props => props.primaryColor}; 22 | stroke: ${props => lighten(0.2, props.primaryColor)}; 23 | stroke-width: 1.5; 24 | rx: 4; 25 | ry: 4; 26 | 27 | ${is('consul')` 28 | stroke: ${props => darken(0.2, props.secondaryColor)}; 29 | fill: ${props => props.secondaryColor}; 30 | `}; 31 | 32 | ${isNot('active')` 33 | stroke: ${props => darken(0.2, props.secondaryColor)}; 34 | fill: ${props => props.secondaryColor}; 35 | `}; 36 | 37 | ${is('connected')` 38 | cursor: move; 39 | `}; 40 | `; 41 | 42 | export const GraphShadowRect = styled.rect` 43 | fill: ${props => props.primaryColor && darken(0.1, props.primaryColor)}; 44 | opacity: 0.1; 45 | rx: 4; 46 | ry: 4; 47 | 48 | ${is('consul')` 49 | fill: ${props => 50 | props.secondaryColor && darken(0.1, props.secondaryColor)}; 51 | `}; 52 | `; 53 | 54 | export const GraphTitle = styled.text` 55 | font-size: 16px; 56 | font-weight: 600; 57 | fill: ${props => props.secondaryColor}; 58 | 59 | ${is('consul')` 60 | fill: ${props => props.primaryColor}; 61 | `}; 62 | 63 | ${isNot('active')` 64 | fill: ${props => props.primaryColor}; 65 | `}; 66 | 67 | cursor: pointer; 68 | `; 69 | 70 | export const GraphSubtitle = styled.text` 71 | text-overflow: ellipsis; 72 | font-size: 12px; 73 | font-weight: 600; 74 | fill: ${props => props.secondaryColor}; 75 | 76 | ${is('consul')` 77 | fill: ${props => props.primaryColor}; 78 | `}; 79 | 80 | ${isNot('active')` 81 | fill: ${props => props.primaryColor}; 82 | `}; 83 | `; 84 | 85 | export const GraphText = styled.text` 86 | font-weight: normal; 87 | 88 | font-size: 12px; 89 | fill: ${props => props.secondaryColor}; 90 | opacity: 0.8; 91 | transform: translateY(calc(17 * ${props => props.index}px)); 92 | 93 | ${is('consul')` 94 | fill: ${props => props.primaryColor}; 95 | `}; 96 | 97 | ${isNot('active')` 98 | fill: ${props => props.primaryColor}; 99 | `}; 100 | `; 101 | 102 | export const GraphButtonRect = styled.rect` 103 | cursor: pointer; 104 | opacity: 0; 105 | 106 | &:focus { 107 | outline: none; 108 | } 109 | `; 110 | 111 | export const GraphButtonCircle = styled.circle` 112 | fill: ${props => props.secondaryColor}; 113 | 114 | ${is('consul')` 115 | fill: ${props => props.primaryColor}; 116 | `}; 117 | 118 | ${isNot('active')` 119 | fill: ${props => props.primaryColor}; 120 | `}; 121 | `; 122 | 123 | export const GraphHealthyCircle = styled.circle` 124 | fill: ${theme.green}; 125 | `; 126 | -------------------------------------------------------------------------------- /src/data/index.js: -------------------------------------------------------------------------------- 1 | export const graphql = [ 2 | { 3 | id: 'frontend-app', 4 | name: 'Frontend', 5 | status: 'active', 6 | connections: ['graphql-server'], 7 | nodes: [ 8 | { 9 | status: 'running', 10 | count: 1 11 | }, 12 | { 13 | status: 'failed', 14 | count: 1 15 | } 16 | ], 17 | instancesActive: true, 18 | instancesHealthy: { 19 | total: 2, 20 | healthy: 0 21 | }, 22 | transitionalStatus: false, 23 | reversed: true 24 | }, 25 | { 26 | id: 'graphql-server', 27 | name: 'GraphQL', 28 | status: 'active', 29 | connections: ['api-server'], 30 | nodes: [ 31 | { 32 | status: 'running', 33 | count: 2 34 | } 35 | ], 36 | instancesActive: true, 37 | instancesHealthy: { 38 | total: 2, 39 | healthy: 2 40 | }, 41 | transitionalStatus: false, 42 | reversed: true 43 | }, 44 | { 45 | id: 'api-server', 46 | name: 'API', 47 | status: 'active', 48 | connections: ['graphql-server'], 49 | nodes: [ 50 | { 51 | status: 'running', 52 | count: 1 53 | }, 54 | { 55 | status: 'failed', 56 | count: 1 57 | }, 58 | { 59 | status: 'unknown', 60 | count: 1 61 | } 62 | ], 63 | instancesActive: true, 64 | instancesHealthy: { 65 | total: 3, 66 | healthy: 2 67 | }, 68 | transitionalStatus: false, 69 | reversed: false 70 | } 71 | ]; 72 | 73 | export const graphqlJoyent = [ 74 | { 75 | id: 'af6a5cd2-291f-490b-bf3b-141b010635db', 76 | name: 'Frontend really long frontend man', 77 | status: 'active', 78 | connections: ['aea06a05-830a-46d3-bdc1-9dcba97303de'], 79 | nodes: [ 80 | { 81 | status: 'running', 82 | count: 1 83 | }, 84 | { 85 | status: 'failed', 86 | count: 1 87 | } 88 | ], 89 | instancesActive: true, 90 | instancesHealthy: { 91 | total: 2, 92 | healthy: 0 93 | }, 94 | transitionalStatus: false, 95 | reversed: true 96 | }, 97 | { 98 | id: 'af6a5cd2-291f-490b-bf3b-asdasads', 99 | name: 'GraphQL', 100 | status: 'active', 101 | connections: ['af6a5cd2-291f-490b-bf3b-141b010635db'], 102 | nodes: [ 103 | { 104 | status: 'running', 105 | count: 2 106 | } 107 | ], 108 | instancesActive: true, 109 | instancesHealthy: { 110 | total: 2, 111 | healthy: 2 112 | }, 113 | transitionalStatus: false, 114 | reversed: true 115 | }, 116 | { 117 | id: 'af6a5cd2-291f-490b-bf3b-141b010635dbs', 118 | name: 'API', 119 | status: 'active', 120 | connections: ['af6a5cd2-291f-490b-bf3b-asdasads'], 121 | nodes: [ 122 | { 123 | status: 'running', 124 | count: 1 125 | }, 126 | { 127 | status: 'failed', 128 | count: 1 129 | }, 130 | { 131 | status: 'unknown', 132 | count: 1 133 | } 134 | ], 135 | instancesActive: true, 136 | instancesHealthy: { 137 | total: 3, 138 | healthy: 2 139 | }, 140 | transitionalStatus: false, 141 | reversed: false 142 | } 143 | ]; 144 | 145 | export const one = { 146 | name: 'stuff' 147 | }; 148 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-topology", 3 | "version": "0.1.0", 4 | "author": "YLD", 5 | "main": "src/index.js", 6 | "license": "MPL-2.0", 7 | "dependencies": { 8 | "babel-plugin-transform-react-remove-prop-types": "^0.4.10", 9 | "chart.js": "^2.7.0", 10 | "d3": "^4.11.0", 11 | "force-array": "^3.1.0", 12 | "fs": "^0.0.1-security", 13 | "joyent-ui-toolkit": "^2.1.0", 14 | "lodash.difference": "^4.5.0", 15 | "lodash.differenceby": "^4.8.0", 16 | "normalized-styled-components": "^1.0.17", 17 | "polished": "^1.8.1", 18 | "prop-types": "^15.5.10", 19 | "react": "^16.0.0", 20 | "react-dom": "^16.0.0", 21 | "react-redux": "^5.0.6", 22 | "react-router-dom": "^4.2.2", 23 | "react-scripts": "^1.0.14", 24 | "reduce-css-calc": "^2.1.1", 25 | "redux": "^3.7.2", 26 | "redux-form": "^7.1.1", 27 | "remcalc": "^1.0.9", 28 | "styled-components": "^2.2.1", 29 | "styled-is": "^1.1.0" 30 | }, 31 | "scripts": { 32 | "start": "styleguidist server", 33 | "build": "styleguidist build", 34 | "lint:js": "eslint src", 35 | "jest": "jest --env=jsdom", 36 | "test": "run-s lint:js lint:css jest", 37 | "lint:css": "stylelint './src/**/*.js'", 38 | "precommit": "lint-staged" 39 | }, 40 | "devDependencies": { 41 | "babel-loader": "^7.1.2", 42 | "babel-plugin-add-module-exports": "^0.2.1", 43 | "babel-plugin-transform-es3-member-expression-literals": "^6.22.0", 44 | "babel-plugin-transform-es3-property-literals": "^6.22.0", 45 | "babel-plugin-uglify": "^1.0.2", 46 | "babel-preset-env": "^1.6.0", 47 | "babel-preset-es2015": "^6.24.1", 48 | "babel-preset-joyent-portal": "^3.2.0", 49 | "babel-preset-stage-0": "^6.24.1", 50 | "bundlesize": "^0.15.3", 51 | "css-loader": "^0.28.7", 52 | "enzyme": "^3.1.0", 53 | "enzyme-adapter-react-16": "^1.0.1", 54 | "enzyme-to-json": "^3.1.2", 55 | "eslint": "^4.7.2", 56 | "eslint-config-joyent-portal": "^3.1.0", 57 | "eslint-config-prettier": "^2.6.0", 58 | "eslint-config-react-app": "^2.0.1", 59 | "eslint-config-xo-space": "^0.16.0", 60 | "eslint-plugin-flowtype": "^2.39.1", 61 | "eslint-plugin-import": "^2.7.0", 62 | "eslint-plugin-jsx-a11y": "^6.0.2", 63 | "eslint-plugin-react": "^7.4.0", 64 | "file-loader": "^1.1.5", 65 | "husky": "^0.14.3", 66 | "identity-obj-proxy": "^3.0.0", 67 | "jest": "^21.1.0", 68 | "joyent-react-scripts": "^2.3.0", 69 | "lint-staged": "^4.2.2", 70 | "npm-run-all": "^4.1.1", 71 | "react-docgen-displayname-handler": "^1.0.1", 72 | "react-styleguidist": "^6.0.25", 73 | "react-test-renderer": "^16.0.0", 74 | "redrun": "^5.9.18", 75 | "sinon": "^4.0.1", 76 | "style-loader": "^0.19.0", 77 | "stylelint": "^8.2.0", 78 | "stylelint-config-joyent-portal": "^2.0.1", 79 | "svg-inline-loader": "^0.8.0", 80 | "url-loader": "^0.6.2" 81 | }, 82 | "lint-staged": { 83 | "src/**/*.js": [ 84 | "jest --bail --findRelatedTests", 85 | "eslint --fix", 86 | "stylelint", 87 | "git add -A" 88 | ] 89 | }, 90 | "jest": { 91 | "moduleNameMapper": { 92 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 93 | "\\.(css|less|sass|scss|postcss)$": "identity-obj-proxy" 94 | }, 95 | "testPathIgnorePatterns": [ 96 | "/node_modules/", 97 | "/dist/" 98 | ], 99 | "collectCoverage": true, 100 | "snapshotSerializers": [ 101 | "enzyme-to-json/serializer" 102 | ] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/__tests__/Topology.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import Enzyme, { mount, shallow, render } from 'enzyme'; 4 | import { graphqlJoyent, graphql, one } from '../data'; 5 | import Topology from '../'; 6 | 7 | Enzyme.configure({ adapter: new Adapter() }); 8 | it('Mounts without throwing', () => { 9 | const tree = mount(); 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | 13 | it('Shallow without throwing', () => { 14 | const tree = shallow(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | 18 | it('Renders without throwing', () => { 19 | const tree = render(); 20 | expect(tree).toMatchSnapshot(); 21 | }); 22 | 23 | it('Renders with mapped options', () => { 24 | const tree = render( 25 | 26 | ); 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | 30 | it('Calls Quick Options', () => { 31 | const fn = jest.fn(); 32 | const tree = mount( 33 | 34 | ); 35 | tree 36 | .find('.kMHeTL') 37 | .first() 38 | .simulate('click'); 39 | expect(fn).toHaveBeenCalled(); 40 | expect(tree).toMatchSnapshot(); 41 | }); 42 | 43 | it('Calls Title', () => { 44 | const fn = jest.fn(); 45 | const tree = mount(); 46 | tree 47 | .find('.guGhPK') 48 | .first() 49 | .simulate('click'); 50 | expect(fn).toHaveBeenCalled(); 51 | expect(tree).toMatchSnapshot(); 52 | }); 53 | 54 | it('Drags node', () => { 55 | const tree = mount(); 56 | tree 57 | .find('.kTflvy') 58 | .first() 59 | .simulate('dragStart'); 60 | expect(tree).toMatchSnapshot(); 61 | }); 62 | 63 | it('Drags', () => { 64 | const tree = mount(); 65 | tree 66 | .find('.kTflvy') 67 | .first() 68 | .simulate('mouseMove'); 69 | expect(tree).toMatchSnapshot(); 70 | 71 | tree 72 | .find('.kTflvy') 73 | .first() 74 | .simulate('mouseUp'); 75 | expect(tree).toMatchSnapshot(); 76 | 77 | tree 78 | .find('.kTflvy') 79 | .first() 80 | .simulate('touchEnd'); 81 | expect(tree).toMatchSnapshot(); 82 | 83 | tree 84 | .find('.kTflvy') 85 | .first() 86 | .simulate('touchCancel'); 87 | expect(tree).toMatchSnapshot(); 88 | }); 89 | 90 | it('mounts width colors', () => { 91 | const tree = mount( 92 | 97 | ); 98 | expect(tree).toMatchSnapshot(); 99 | }); 100 | 101 | it('mounts with only name', () => { 102 | const tree = mount(); 103 | expect(tree).toMatchSnapshot(); 104 | }); 105 | 106 | it('mounts with nothing', () => { 107 | const tree = mount(); 108 | expect(tree).toMatchSnapshot(); 109 | }); 110 | 111 | it('mounts with changing props', () => { 112 | const tree = mount(); 113 | tree.setProps({ services: graphqlJoyent }); 114 | tree.setProps({ services: [] }); 115 | expect(tree).toMatchSnapshot(); 116 | }); 117 | 118 | it('mounts with changing props', () => { 119 | const tree = mount(); 120 | tree.setProps({ services: one }); 121 | tree.setProps({ services: graphqlJoyent }); 122 | expect(tree).toMatchSnapshot(); 123 | }); 124 | 125 | it('removes eventListeners on unmount', () => { 126 | const tree = mount(); 127 | tree.unmount(); 128 | expect(tree).toMatchSnapshot(); 129 | }); 130 | -------------------------------------------------------------------------------- /src/functions.js: -------------------------------------------------------------------------------- 1 | import Constants from './constants'; 2 | 3 | const getAngleFromPoints = (source, target) => { 4 | const lineAngle = Math.atan2(target.y - source.y, target.x - source.x); 5 | const lineAngleDeg = lineAngle * 180 / Math.PI; 6 | const zeroToThreeSixty = lineAngleDeg < 0 ? 360 + lineAngleDeg : lineAngleDeg; 7 | 8 | return zeroToThreeSixty; 9 | }; 10 | 11 | const getPosition = (angle, positions, position, noCorners = false) => { 12 | const positionIndex = noCorners 13 | ? Math.round(angle / 90) * 2 14 | : Math.round(angle / 45); 15 | 16 | const offsetPosition = positions[positionIndex]; 17 | 18 | return { 19 | id: offsetPosition.id, 20 | x: position.x + offsetPosition.x, 21 | y: position.y + offsetPosition.y 22 | }; 23 | }; 24 | 25 | const getPositions = (rect, halfCorner = 0) => [ 26 | { 27 | id: 'r', 28 | x: rect.right, 29 | y: 0 30 | }, 31 | { 32 | id: 'br', 33 | x: rect.right - halfCorner, 34 | y: rect.bottom - halfCorner 35 | }, 36 | { 37 | id: 'b', 38 | x: 0, 39 | y: rect.bottom 40 | }, 41 | { 42 | id: 'bl', 43 | x: rect.left + halfCorner, 44 | y: rect.bottom - halfCorner 45 | }, 46 | { 47 | id: 'l', 48 | x: rect.left, 49 | y: 0 50 | }, 51 | { 52 | id: 'tl', 53 | x: rect.left + halfCorner, 54 | y: rect.top + halfCorner 55 | }, 56 | { 57 | id: 't', 58 | x: 0, 59 | y: rect.top 60 | }, 61 | { 62 | id: 'tr', 63 | x: rect.right - halfCorner, 64 | y: rect.top + halfCorner 65 | }, 66 | { 67 | id: 'r', 68 | x: rect.right, 69 | y: 0 70 | } 71 | ]; 72 | 73 | const calculateLineLayout = ({ source, target }) => { 74 | // Actually, this will need to be got dynamically, in case them things are different sizes 75 | // yeah right, now you'll get to do exactly that 76 | 77 | const halfCorner = 2; 78 | 79 | const sourcePositions = getPositions(source.nodeRect, halfCorner); 80 | const sourceAngle = getAngleFromPoints(source, target); 81 | const sourcePosition = getPosition(sourceAngle, sourcePositions, source); 82 | 83 | const targetPositions = getPositions(target.nodeRect, halfCorner); 84 | const targetAngle = getAngleFromPoints(target, sourcePosition); 85 | const targetPosition = getPosition(targetAngle, targetPositions, target); // , true); 86 | 87 | const arrowAngle = getAngleFromPoints(sourcePosition, targetPosition); 88 | 89 | return { 90 | source, 91 | target, 92 | sourcePosition, 93 | targetPosition, 94 | arrowAngle 95 | }; 96 | }; 97 | 98 | const getStatusesLength = data => 99 | data.transitionalStatus ? 1 : (data.instanceStatuses || []).length; 100 | 101 | const getStatusesHeight = data => { 102 | const statuses = data.children 103 | ? data.children.reduce( 104 | (statuses, child) => statuses + getStatusesLength(child), 105 | 0 106 | ) 107 | : getStatusesLength(data); 108 | 109 | return statuses ? Constants.statusHeight * statuses + 6 : 0; 110 | }; 111 | 112 | const getContentRect = (data, isChild = false) => { 113 | const contentSize = isChild 114 | ? Constants.childContentSize 115 | : Constants.contentSize; 116 | 117 | const { height } = contentSize; 118 | const contentHeight = height + getStatusesHeight(data); 119 | 120 | return { 121 | ...Constants.contentPosition, 122 | width: contentSize.width, 123 | height: contentHeight 124 | }; 125 | }; 126 | 127 | const getNodeRect = data => { 128 | const nodeSize = data.children 129 | ? Constants.nodeSizeWithChildren 130 | : Constants.nodeSize; 131 | 132 | const { width, height } = nodeSize; 133 | const nodeHeight = height + getStatusesHeight(data); 134 | 135 | return { 136 | left: -width / 2, 137 | right: width / 2, 138 | top: -height / 2, 139 | bottom: nodeHeight - height / 2, 140 | width, 141 | height: nodeHeight 142 | }; 143 | }; 144 | 145 | export { getContentRect, getNodeRect, calculateLineLayout }; 146 | -------------------------------------------------------------------------------- /src/node/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Constants from '../constants'; 4 | import { getContentRect } from '../functions'; 5 | import GraphNodeTitle from './title'; 6 | import GraphNodeButton from './button'; 7 | import GraphNodeContent from './content'; 8 | import { GraphNodeRect, GraphShadowRect } from './shapes'; 9 | 10 | const GraphNode = ({ 11 | primaryColor, 12 | secondaryColor, 13 | data, 14 | index, 15 | onDragStart, 16 | onTitleClick, 17 | onQuickActions 18 | }) => { 19 | const { left, top, width, height } = data.nodeRect; 20 | const { 21 | connections, 22 | id, 23 | children, 24 | instancesActive, 25 | isConsul, 26 | reversed 27 | } = data; 28 | const reverse = isConsul || reversed; 29 | 30 | let x = data.x; 31 | let y = data.y; 32 | 33 | if ((connections || []).length !== 0) { 34 | x = data.x + left; 35 | y = data.y + top; 36 | } 37 | 38 | const onButtonClick = evt => { 39 | const tooltipPosition = { 40 | x: data.x + Constants.buttonRect.x + Constants.buttonRect.width / 2, 41 | y: data.y + Constants.buttonRect.y + Constants.buttonRect.height 42 | }; 43 | 44 | if ((connections || []).length !== 0) { 45 | tooltipPosition.x += left; 46 | tooltipPosition.y += top; 47 | } 48 | 49 | const d = { 50 | service: data, 51 | position: { 52 | left: tooltipPosition.x, 53 | top: tooltipPosition.y 54 | } 55 | }; 56 | 57 | if (onQuickActions) onQuickActions(evt, d); 58 | }; 59 | 60 | const handleTitleClick = evt => onTitleClick(evt, { service: data }); 61 | const onStart = evt => { 62 | evt.preventDefault(); 63 | onDragStart(evt, id); 64 | }; 65 | 66 | const nodeRectEvents = 67 | (connections || []).length === 0 68 | ? {} 69 | : { 70 | onMouseDown: onStart, 71 | onTouchStart: onStart 72 | }; 73 | 74 | const nodeContent = children ? ( 75 | children.reduce( 76 | (acc, d, i) => { 77 | acc.children.push( 78 | 87 | ); 88 | acc.y += getContentRect(d, true).height; 89 | return acc; 90 | }, 91 | { y: Constants.contentRect.y, children: [] } 92 | ).children 93 | ) : ( 94 | 99 | ); 100 | 101 | const nodeShadow = instancesActive ? ( 102 | 110 | ) : null; 111 | 112 | return ( 113 | 114 | {nodeShadow} 115 | 127 | 133 | 141 | {nodeContent} 142 | 143 | ); 144 | }; 145 | 146 | GraphNode.propTypes = { 147 | data: PropTypes.object.isRequired, 148 | index: PropTypes.number.isRequired, 149 | onDragStart: PropTypes.func, 150 | onTitleClick: PropTypes.func, 151 | onQuickActions: PropTypes.func, 152 | /** 153 | * Color of each node 154 | */ 155 | primaryColor: PropTypes.string, 156 | /** 157 | * Color of each node when reversed 158 | */ 159 | secondaryColor: PropTypes.string 160 | }; 161 | 162 | export default GraphNode; 163 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | noun_21272_cc 3 | Created using Figma 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Svg } from 'normalized-styled-components'; 3 | import PropTypes from 'prop-types'; 4 | import difference from 'lodash.difference'; 5 | import differenceBy from 'lodash.differenceby'; 6 | 7 | import Constants from './constants'; 8 | import createSimulation from './simulation'; 9 | import TopologyNode from './node'; 10 | import TopologyLink from './link'; 11 | import TopologyLinkArrow from './link/arrow'; 12 | import { getNodeRect, calculateLineLayout } from './functions'; 13 | import { instanceStatuses, instances, instancesHealthy } from './prop-types'; 14 | 15 | const StyledSvg = Svg.extend` 16 | width: ${props => props.size.width}px; 17 | height: ${props => props.size.height}px; 18 | max-width: 100%; 19 | font-family: arial; 20 | `; 21 | 22 | class Topology extends React.Component { 23 | componentWillMount() { 24 | this.create(this.props); 25 | } 26 | 27 | componentDidMount() { 28 | this.boundResize = this.handleResize.bind(this); 29 | window.addEventListener('resize', this.boundResize); 30 | } 31 | 32 | componentWillUnmount() { 33 | window.removeEventListener('resize', this.boundResize); 34 | } 35 | 36 | shouldComponentUpdate = () => false; 37 | 38 | getChangedConnections(services, nextServices) { 39 | return nextServices.reduce((changed, nextService) => { 40 | if (changed.added || changed.removed) { 41 | return changed; 42 | } 43 | const service = services 44 | .filter(service => service.id === nextService.id) 45 | .shift(); 46 | const connectionsAdded = difference( 47 | nextService.connections || [], 48 | service.connections || [] 49 | ).length; 50 | // there's a new connection, we need to redraw 51 | if (connectionsAdded) { 52 | return { added: true }; 53 | } 54 | const connectionsRemoved = difference( 55 | service.connections || [], 56 | nextService.connections || [] 57 | ).length; 58 | // we'll need to remove the offending connections from links 59 | if (connectionsRemoved) { 60 | return { removed: true }; 61 | } 62 | return changed; 63 | }, {}); 64 | } 65 | 66 | getNextLinks(nextServices) { 67 | const links = this.state.links; 68 | return links.reduce((nextLinks, link) => { 69 | const sourceExists = nextServices.filter( 70 | nextService => nextService.id === link.source.id 71 | ); 72 | if (sourceExists.length) { 73 | const source = sourceExists.shift(); 74 | const targetExists = nextServices.filter( 75 | nextService => nextService.id === link.target.id 76 | ).length; 77 | const connectionExists = source.connections.filter( 78 | connection => connection === link.target.id 79 | ).length; 80 | if (targetExists && connectionExists) { 81 | nextLinks.push(link); 82 | } 83 | } 84 | return nextLinks; 85 | }, []); 86 | } 87 | 88 | getNextNodes(nextServices) { 89 | const nodes = this.state.nodes; 90 | // let notConnectedX = 0; 91 | return nodes.reduce((nextNodes, node) => { 92 | const keep = nextServices.filter( 93 | nextService => nextService.id === node.id 94 | ).length; 95 | if (keep) { 96 | nextNodes.push(node); 97 | } 98 | return nextNodes; 99 | }, []); 100 | } 101 | 102 | componentWillReceiveProps(nextProps) { 103 | // if we remove a node, it should just be removed from the simulation nodes and links 104 | // if we add a node, then we should recreate the damn thing 105 | // on other updates, we should update the services on the state and that's it 106 | const nextServices = Array.isArray(nextProps.services) 107 | ? nextProps.services.sort() 108 | : [nextProps.services]; 109 | 110 | const connectedNextServices = nextServices.filter( 111 | service => (service.connections || []).length !== 0 112 | ); 113 | const notConnectedNextServices = nextServices.filter( 114 | service => !(service.connections || []).length !== 0 115 | ); 116 | 117 | const { services } = this.state; 118 | if (nextServices.length > services.length) { 119 | // new service added, we need to redraw 120 | this.create(nextProps); 121 | } else if (nextServices.length <= services.length) { 122 | const servicesRemoved = differenceBy(services, nextServices, 'id'); 123 | const servicesChanged = differenceBy(nextServices, services, 'id'); 124 | if ( 125 | servicesChanged.length || 126 | servicesRemoved.length !== services.length - nextServices.length 127 | ) { 128 | this.create(nextProps); 129 | } else { 130 | // check whether there are new connections. if so, we need to redraw 131 | // if we just dropped one, we need to remove it from links 132 | // comparison to yield 3 possible outcomes; no change, added, dropped 133 | const changedConnections = this.getChangedConnections( 134 | services, 135 | nextServices 136 | ); 137 | // if connections are added, we'll need to redraw 138 | if (changedConnections.added) { 139 | this.create(nextProps); 140 | } else if (servicesRemoved.length || changedConnections.removed) { 141 | const nextNodes = this.getNextNodes(connectedNextServices); 142 | const notConnectedNodes = this.getNotConnectedNodes( 143 | notConnectedNextServices 144 | ); 145 | const nextLinks = this.getNextLinks(nextServices); 146 | 147 | this.setState( 148 | { 149 | services: nextServices, 150 | links: nextLinks, 151 | nodes: nextNodes, 152 | notConnectedNodes 153 | }, 154 | () => this.forceUpdate() 155 | ); 156 | } else { 157 | // we've got the same services, no links changed, so we just need to set them to the state 158 | this.setState({ services: nextServices }, () => this.forceUpdate()); 159 | } 160 | } 161 | } 162 | } 163 | 164 | getNotConnectedNodes(notConnectedServices) { 165 | return notConnectedServices.map((notConnectedService, index) => { 166 | const svgSize = this.getContentSize(); 167 | const x = 168 | notConnectedService.isConsul || notConnectedService.reversed 169 | ? svgSize.width - Constants.nodeSize.width 170 | : (Constants.nodeSize.width + 10) * index; 171 | 172 | return { 173 | id: notConnectedService.id, 174 | x, 175 | y: 0 176 | }; 177 | }); 178 | } 179 | 180 | handleResize(evt) { 181 | this.create(this.props); 182 | // resize should just rejig the positions 183 | } 184 | 185 | renameProperty = (service, map) => 186 | Object.assign( 187 | ...Object.keys(map).map(k => ({ 188 | ...service, 189 | [k]: service[map[k]] 190 | })) 191 | ); 192 | 193 | create(props) { 194 | let services = Array.isArray(props.services) 195 | ? props.services.sort() 196 | : [props.services]; 197 | if (props.map) { 198 | services = props.services.map(service => 199 | this.renameProperty(service, props.map) 200 | ); 201 | } 202 | const connectedServices = services.filter( 203 | service => (service.connections || []).length !== 0 204 | ); 205 | const notConnectedServices = services.filter( 206 | service => !(service.connections || []).length !== 0 207 | ); 208 | const svgSize = this.getContentSize(); 209 | 210 | const { nodes, links, simulation } = createSimulation( 211 | connectedServices, 212 | svgSize 213 | ); 214 | const notConnectedNodes = this.getNotConnectedNodes(notConnectedServices); 215 | 216 | this.setState( 217 | { 218 | notConnectedNodes, 219 | nodes, 220 | links, 221 | simulation, 222 | services 223 | }, 224 | () => { 225 | this.forceUpdate(); 226 | } 227 | ); 228 | } 229 | 230 | getContentSize() { 231 | const { parentId, width, height } = this.props; 232 | if (parentId && document.getElementById(parentId)) { 233 | const size = document.getElementById(parentId).getBoundingClientRect(); 234 | 235 | return { 236 | width: window.innerWidth < size.width ? window.innerWidth : size.width, 237 | height: size.height 238 | }; 239 | } 240 | 241 | return { 242 | width: window.innerWidth < width ? window.innerWidth : width, 243 | height 244 | }; 245 | } 246 | 247 | constrainNodePosition(x, y, nodeRect, children = false) { 248 | const svgSize = this.getContentSize(); 249 | 250 | /* const nodeRect = children 251 | ? Constants.nodeRectWithChildren 252 | : Constants.nodeRect; */ 253 | 254 | if (x < nodeRect.right + 2) { 255 | x = nodeRect.right + 2; 256 | } else if (x > svgSize.width + nodeRect.left - 2) { 257 | x = svgSize.width + nodeRect.left - 2; 258 | } 259 | 260 | if (y < -nodeRect.top + 2) { 261 | y = -nodeRect.top + 2; 262 | } else if (y > svgSize.height - nodeRect.bottom - 2) { 263 | y = svgSize.height - nodeRect.bottom - 2; 264 | } 265 | 266 | return { 267 | x, 268 | y 269 | }; 270 | } 271 | 272 | findNode = nodeId => 273 | this.state.nodes.reduce( 274 | (acc, simNode, index) => (simNode.id === nodeId ? simNode : acc), 275 | {} 276 | ); 277 | 278 | getConstrainedNodePosition(nodeId, nodeRect, children = false) { 279 | const node = this.findNode(nodeId); 280 | return this.constrainNodePosition(node.x, node.y, nodeRect, children); 281 | } 282 | 283 | getNotConnectedNodePosition = nodeId => 284 | this.state.notConnectedNodes.filter(ncn => ncn.id === nodeId).shift(); 285 | 286 | findNodeData = (nodesData, nodeId) => 287 | nodesData.filter(nodeData => nodeData.id === nodeId).shift(); 288 | 289 | setDragInfo(dragging, nodeId = null, position = {}) { 290 | this.dragInfo = { 291 | dragging, 292 | nodeId, 293 | position 294 | }; 295 | } 296 | 297 | render() { 298 | const { onQuickActionsClick, onTitleClick } = this.props; 299 | 300 | const { nodes, links, services } = this.state; 301 | 302 | const nodesData = services.map((service, index) => { 303 | const nodeRect = getNodeRect(service); 304 | const nodePosition = 305 | (service.connections || []).length === 0 306 | ? this.getNotConnectedNodePosition(service.id) 307 | : this.getConstrainedNodePosition( 308 | service.id, 309 | nodeRect, 310 | service.children 311 | ); 312 | 313 | return { 314 | ...service, 315 | ...nodePosition, 316 | nodeRect 317 | }; 318 | }); 319 | 320 | const linksData = links 321 | .map((link, index) => ({ 322 | source: this.findNodeData(nodesData, link.source.id), 323 | target: this.findNodeData(nodesData, link.target.id) 324 | })) 325 | .map((linkData, index) => { 326 | return calculateLineLayout(linkData, index); 327 | }); 328 | 329 | const onDragStart = (evt, nodeId) => { 330 | // It's this node's position that we'll need to update 331 | 332 | const x = evt.changedTouches ? evt.changedTouches[0].pageX : evt.clientX; 333 | const y = evt.changedTouches ? evt.changedTouches[0].pageY : evt.clientY; 334 | 335 | this.setDragInfo(true, nodeId, { 336 | x, 337 | y 338 | }); 339 | }; 340 | 341 | const onDragMove = evt => { 342 | if (this.dragInfo && this.dragInfo.dragging) { 343 | const x = evt.changedTouches 344 | ? evt.changedTouches[0].pageX 345 | : evt.clientX; 346 | const y = evt.changedTouches 347 | ? evt.changedTouches[0].pageY 348 | : evt.clientY; 349 | 350 | const offset = { 351 | x: x - this.dragInfo.position.x, 352 | y: y - this.dragInfo.position.y 353 | }; 354 | 355 | const dragNodes = nodes.map((simNode, index) => { 356 | if (simNode.id === this.dragInfo.nodeId) { 357 | return { 358 | ...simNode, 359 | x: simNode.x + offset.x, 360 | y: simNode.y + offset.y 361 | }; 362 | } 363 | return { 364 | ...simNode 365 | }; 366 | }); 367 | 368 | this.setState( 369 | { 370 | nodes: dragNodes 371 | }, 372 | () => this.forceUpdate() 373 | ); 374 | 375 | this.setDragInfo(true, this.dragInfo.nodeId, { 376 | x, 377 | y 378 | }); 379 | } 380 | }; 381 | 382 | const onDragEnd = evt => { 383 | this.setDragInfo(false); 384 | }; 385 | 386 | const renderedNode = (n, index) => { 387 | const { primaryColor, secondaryColor } = this.props; 388 | return ( 389 | 399 | ); 400 | }; 401 | 402 | const renderedLink = (l, index) => ( 403 | 404 | ); 405 | 406 | const renderedLinkArrow = (l, index) => ( 407 | 408 | ); 409 | 410 | const renderedNodes = 411 | this.dragInfo && this.dragInfo.dragging 412 | ? nodesData 413 | .filter((n, index) => n.id !== this.dragInfo.nodeId) 414 | .map((n, index) => renderedNode(n, index)) 415 | : nodesData.map((n, index) => renderedNode(n, index)); 416 | 417 | const renderedLinks = linksData.map((l, index) => renderedLink(l, index)); 418 | 419 | const renderedLinkArrows = 420 | this.dragInfo && this.dragInfo.dragging 421 | ? linksData 422 | .filter((l, index) => l.target.id !== this.dragInfo.nodeId) 423 | .map((l, index) => renderedLinkArrow(l, index)) 424 | : linksData.map((l, index) => renderedLinkArrow(l, index)); 425 | 426 | const dragNode = 427 | !this.dragInfo || !this.dragInfo.dragging 428 | ? null 429 | : renderedNode( 430 | nodesData.reduce((dragNode, n, index) => { 431 | if (n.id === this.dragInfo.nodeId) { 432 | return n; 433 | } 434 | return dragNode; 435 | }, {}) 436 | ); 437 | 438 | const dragLinkArrow = 439 | !this.dragInfo || 440 | !this.dragInfo.dragging || 441 | renderedLinkArrows.length === renderedLinks.length 442 | ? null 443 | : renderedLinkArrow( 444 | linksData.reduce((dragLinkArrow, l, index) => { 445 | if (l.target.id === this.dragInfo.nodeId) { 446 | return l; 447 | } 448 | return dragLinkArrow; 449 | }, {}) 450 | ); 451 | 452 | return ( 453 | 462 | {renderedNodes} 463 | {renderedLinks} 464 | {renderedLinkArrows} 465 | {dragNode} 466 | {dragLinkArrow} 467 | 468 | ); 469 | } 470 | } 471 | 472 | Topology.propTypes = { 473 | /** What should happen when the quick actions are clicked */ 474 | onQuickActionsClick: PropTypes.func, 475 | /** What should happen when the title of any node is clicked */ 476 | onTitleClick: PropTypes.func, 477 | /** 478 | * The real magic , this is where you pass all of the services you want to see shown 479 | */ 480 | services: PropTypes.arrayOf( 481 | PropTypes.shape({ 482 | /** 483 | * id of node 484 | */ 485 | id: PropTypes.string.isRequired, 486 | /** 487 | * name of the node 488 | */ 489 | name: PropTypes.string.isRequired, 490 | /** 491 | * How this node is doing 492 | * ```js 493 | ['active', 'running', 'failed', 'unknown'] 494 | * ``` 495 | */ 496 | status: PropTypes.oneOf(['active', 'running', 'failed', 'unknown']), 497 | /** 498 | * id of the nodes this node is connected to 499 | */ 500 | connections: PropTypes.array, 501 | /** 502 | * the status of the instances inside this node 503 | * ```js 504 | { 505 | count: PropTypes.number.isRequired, 506 | status: PropTypes.oneOf(['active', 'running', 'failed', 'unknown']), 507 | healthy: PropTypes.string 508 | } 509 | * ``` 510 | */ 511 | instanceStatuses: PropTypes.arrayOf(instanceStatuses), 512 | /** 513 | * Are the instances active ? 514 | */ 515 | instancesActive: PropTypes.bool, 516 | /** 517 | * The count of instances that are healthy 518 | * ```js 519 | { 520 | total: PropTypes.number, // Total instances 521 | healthy: PropTypes.number 522 | } 523 | * ``` 524 | */ 525 | instancesHealthy, 526 | /** 527 | * The transitional status 528 | */ 529 | transitionalStatus: PropTypes.bool, 530 | /** 531 | * Should this use the reverse color scheme ? 532 | */ 533 | reversed: PropTypes.bool 534 | }) 535 | ), 536 | /** 537 | * Width of the svg. 538 | * Needs to be a number and will always be converted into px 539 | */ 540 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 541 | /** 542 | * Height of the svg. 543 | * Needs to be a number and will always be converted into px 544 | */ 545 | height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 546 | /** If you have a parent already with a width and height you can pass the id and that will be used */ 547 | parentId: PropTypes.string, 548 | /** 549 | * Color of each node 550 | */ 551 | primaryColor: PropTypes.string, 552 | /** 553 | * Color of each node when reversed 554 | */ 555 | secondaryColor: PropTypes.string, 556 | /** 557 | * If your object is different from ours you can map your properties to match what the component understands. 558 | * If you have `nodes` instead of `instanceStatuses` you can pass `map={{instanceStatuses: 'nodes'}}` 559 | */ 560 | map: PropTypes.shape 561 | }; 562 | 563 | Topology.defaultProps = { 564 | width: 600, 565 | height: 600, 566 | onQuickActionsClick: () => {}, 567 | onTitleClick: () => {}, 568 | services: [], 569 | primaryColor: '#343434', 570 | secondaryColor: '#FFF' 571 | }; 572 | 573 | export default Topology; 574 | 575 | export { default as TopologyNode } from './node'; 576 | export { default as TopologyLink } from './link'; 577 | --------------------------------------------------------------------------------