├── __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 | 
2 |
3 | [](https://travis-ci.org/yldio/react-topology)
4 | [](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 |
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 |
--------------------------------------------------------------------------------