Signal graph (#49)

* Initial signal graph

* Minor update

* Change graph using object mutator

* Added SignalServer graph

* Remove d3 dependencies

* fixed signal kube system information

* updated gem

* Minor fix

* update configuration

* /api for signal

* apollo1

* Added visx network graph

* Remove info table.

* Fixed tooltip zIndex

Co-authored-by: Martin Acosta <Martín Acosta>
Co-authored-by: Thomas E Lackey <telackey@bozemanpass.com>
Co-authored-by: Thomas E Lackey <thomas@wireline.io>
This commit is contained in:
Martín Acosta 2020-10-07 09:48:44 -07:00 committed by GitHub
parent afa99a564c
commit e099fca2f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1455 additions and 956 deletions

View File

@ -30,11 +30,11 @@ services:
signal:
server: 'wss://kube.local/dxos/signal'
api: 'https://kube.local/dxos/signal'
api: 'https://kube.local/dxos/signal/api'
ipfs:
server: 'https://kube.local/dxos/ipfs/api'
gateway: 'https://kube.local/dxos/ipfs/gateway'
wellknown:
endpoint: 'https://kube.local/.well-known/dxos'
endpoint: 'https://kube.local/.well-known/dxos'

View File

@ -30,11 +30,11 @@ services:
signal:
server: 'ws://127.0.0.1:4000'
api: 'http://127.0.0.1:4000'
api: 'http://127.0.0.1:4000/api'
ipfs:
server: 'http://127.0.0.1:5001'
gateway: 'http://127.0.0.1:8888/ipfs/'
wellknown:
endpoint: 'http://127.0.0.1:9000/.well-known/dxos'
endpoint: 'http://127.0.0.1:9000/.well-known/dxos'

View File

@ -27,11 +27,11 @@ routes:
webui: '/dxos/wns/console'
signal:
api: '/dxos/signal'
api: '/dxos/signal/api'
ipfs:
server: '/dxos/ipfs/api'
gateway: '/dxos/ipfs/gateway'
wellknown:
endpoint: '/.well-known/dxos'
endpoint: '/.well-known/dxos'

View File

@ -30,7 +30,7 @@ services:
signal:
server: 'wss://apollo1.kube.moon.dxos.network/dxos/signal'
api: 'https://apollo1.kube.moon.dxos.network/dxos/signal'
api: 'https://apollo1.kube.moon.dxos.network/dxos/signal/api'
ipfs:
server: 'https://apollo1.kube.moon.dxos.network/dxos/ipfs/api'

View File

@ -19,7 +19,7 @@
"author": "DXOS.org",
"license": "GPL-3.0",
"browserslist": [
"> 5%"
"> 2%"
],
"jest": {
"testEnvironment": "node"
@ -28,19 +28,25 @@
"@apollo/react-components": "^3.1.5",
"@apollo/react-hooks": "^3.1.5",
"@babel/runtime": "^7.8.7",
"@dxos/debug": "^1.0.0-beta.20",
"@dxos/gem-core": "^1.0.0-beta.11",
"@dxos/debug": "^1.0.0-beta.2",
"@dxos/gem-core": "^1.0.0-beta.25",
"@dxos/react-ux": "^1.1.0-beta.0",
"@material-ui/core": "^4.10.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.54",
"@rehooks/component-size": "^1.0.3",
"@visx/network": "^1.0.0",
"@visx/tooltip": "^1.0.0",
"@visx/zoom": "^1.0.0",
"@wirelineio/registry-client": "^1.1.0-beta.2",
"apollo-cache-inmemory": "^1.6.6",
"apollo-client": "^2.6.10",
"apollo-link": "^1.2.14",
"apollo-link-http": "^1.5.17",
"build-url": "^2.0.0",
"clsx": "^1.1.0",
"compare-versions": "^3.6.0",
"d3-force": "^2.1.1",
"debug": "^4.1.1",
"graphql-tag": "^2.10.3",
"lodash.defaultsdeep": "^4.6.1",

View File

@ -3,6 +3,7 @@
//
import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { createHttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
@ -26,7 +27,7 @@ export const graphqlApi = config => {
*/
export const clientFactory = config => {
// https://www.apollographql.com/docs/link/
const link = createHttpLink({
const defaultLink = createHttpLink({
uri: graphqlApi(config),
// TODO(burdon): Authentication: send signed message to server (from client wallet).
@ -36,11 +37,21 @@ export const clientFactory = config => {
}
});
const serviceLinks = {
signal: createHttpLink({
uri: config.services.signal.api
})
};
// https://www.apollographql.com/docs/react/api/apollo-client/
return new ApolloClient({
connectToDevTools: true,
cache: new InMemoryCache(),
resolvers: createResolvers(config),
link
link: ApolloLink.split(
operation => operation.getContext().api && serviceLinks[operation.getContext().api],
operation => serviceLinks[operation.getContext().api].request(operation),
defaultLink
)
});
};

View File

@ -0,0 +1,173 @@
import React, { useEffect, useRef, useReducer, useMemo, useCallback } from 'react';
import { forceLink, forceSimulation, forceCenter, forceCollide, forceManyBody, forceRadial } from 'd3-force';
import { Graph } from '@visx/network';
import { Tooltip, useTooltip, defaultStyles } from '@visx/tooltip';
import { Zoom } from '@visx/zoom';
import * as colors from '@material-ui/core/colors';
const kNodes = Symbol('nodes');
const kLinks = Symbol('links');
const kUpdate = Symbol('update');
const background = '#101020';
const nodeStyle = {
default: {
fill: colors.pink[400],
r: 20
},
root: {
fill: colors.blue[400],
r: 25
},
adjacent: {
fill: colors.red[400],
r: 20
},
detach: {
fill: colors.grey[400],
r: 15
}
};
const tooltipStyles = {
...defaultStyles,
backgroundColor: 'rgba(53,71,125,0.9)',
color: 'white',
padding: 12,
zIndex: 1000
};
const useForceUpdate = () => useReducer(x => !x, false)[1];
const useForce = ({ width, height, graph }) => {
const forceUpdate = useForceUpdate();
const d3force = useRef(null);
if (d3force.current) {
const oldNodes = d3force.current[kNodes];
const newNodes = graph.nodes.map(n => n.id);
let update = newNodes.filter(id => !oldNodes.includes(id)).length !== oldNodes.filter(id => !newNodes.includes(id)).length;
if (!update) {
const oldLinks = d3force.current[kLinks];
const newLinks = graph.links.map(l => l.id);
update = newLinks.filter(id => !oldLinks.includes(id)).length !== oldLinks.filter(id => !newLinks.includes(id)).length;
}
if (update) {
d3force.current[kUpdate] = true;
}
}
useEffect(() => {
let restart = true;
if (!d3force.current) {
restart = false;
d3force.current = forceSimulation(graph.nodes)
.force('link', forceLink().id(d => d.id).links(graph.links))
.force('charge', forceManyBody().strength(-2000))
.force('collision', forceCollide().strength(1));
d3force.current.on('tick', () => {
forceUpdate();
});
}
d3force.current
.force('center', forceCenter(width / 2, height / 2).strength(0))
.force('r', forceRadial(200).strength(1));
d3force.current[kNodes] = graph.nodes.map(n => n.id);
d3force.current[kLinks] = graph.links.map(l => l.id);
if (!restart) {
return;
}
d3force.current.nodes(graph.nodes);
d3force.current.force('link').links(graph.links);
if (d3force.current[kUpdate]) {
d3force.current[kUpdate] = false;
d3force.current.alpha(1).restart();
} else {
d3force.current.restart();
}
}, [width, height, graph]);
};
const useNode = (handlers) => useMemo(() => function Node ({ node }) {
const { label, type = 'default', ...positions } = node;
const style = nodeStyle[type] || {};
const handleMouseEnter = useCallback((e) => handlers.onMouseEnter(node, e), [node]);
const handleMouseLeave = useCallback((e) => handlers.onMouseLeave(node, e), [node]);
return (
<g>
<circle r={style.r} fill={style.fill} {...positions} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} />
{label &&
<text dy={style.r} dx={style.r + 2} fill='white' fontFamily='arial'>
{label}
</text>}
</g>
);
}, []);
export default function NetworkGraph ({ width, height, graph, onTooltip = () => {} }) {
useForce({ width, height, graph });
const { showTooltip, hideTooltip, tooltipOpen, tooltipData, tooltipLeft = 0, tooltipTop = 0 } = useTooltip();
const Node = useNode({
onMouseEnter: (node, e) => {
showTooltip({
tooltipTop: e.clientY,
tooltipLeft: e.clientX,
tooltipData: node
});
},
onMouseLeave: () => {
hideTooltip();
}
});
return (width <= 0 || height <= 0 || !graph) ? null : (
<div style={{ width, height }}>
{tooltipData && tooltipData.data && tooltipOpen &&
<Tooltip key={Math.random()} left={tooltipLeft} top={tooltipTop} style={tooltipStyles}>
{onTooltip(tooltipData.data)}
</Tooltip>}
<Zoom
width={width}
height={height}
scaleXMin={1 / 2}
scaleXMax={4}
scaleYMin={1 / 2}
scaleYMax={4}
>
{zoom => (
<svg width={width} height={height}>
<rect
width={width}
height={height}
fill={background}
style={{ cursor: zoom.isDragging ? 'grabbing' : 'grab' }}
onTouchStart={zoom.dragStart} // eslint-disable-line
onTouchMove={zoom.dragMove} // eslint-disable-line
onTouchEnd={zoom.dragEnd} // eslint-disable-line
onMouseDown={zoom.dragStart} // eslint-disable-line
onMouseMove={zoom.dragMove} // eslint-disable-line
onMouseUp={zoom.dragEnd} // eslint-disable-line
/>
<g transform={zoom.toString()}>
<Graph graph={graph} top={height / 2} left={width / 2} nodeComponent={Node} />
</g>
</svg>
)}
</Zoom>
</div>
);
}

View File

@ -6,7 +6,6 @@ import React, { useContext } from 'react';
import { useQuery } from '@apollo/react-hooks';
import TableBody from '@material-ui/core/TableBody';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
@ -19,38 +18,53 @@ import { ConsoleContext, useQueryStatusReducer } from '../../../hooks';
const SignalChannels = () => {
const { config } = useContext(ConsoleContext);
const data = useQueryStatusReducer(useQuery(SIGNAL_STATUS, { pollInterval: config.api.intervalQuery }));
const data = useQueryStatusReducer(useQuery(SIGNAL_STATUS, { fetchPolicy: 'no-cache', pollInterval: config.api.pollInterval, context: { api: 'signal' } }));
if (!data) {
return null;
}
const { json: { channels = [] } } = data.signal_status;
const { nodes = [] } = data.signal_status;
const channels = new Map();
nodes.forEach(node => {
const { signal: { topics = [] } } = node;
topics.forEach(topic => {
if (!channels.has(topic.id)) {
channels.set(topic.id, {
id: topic.id,
peers: topic.peers
});
return;
}
const ch = channels.get(topic.id);
ch.peers = [...ch.peers, ...topic.peers];
});
});
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Channel</TableCell>
<TableCell size='small'>Participants</TableCell>
</TableRow>
</TableHead>
<TableBody>
{channels.map(({ channel, peers = [] }) => {
return (
<TableRow key={channel} size='small'>
<TableCell monospace>
{channel}
</TableCell>
<TableCell monospace>
{peers.length}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Channel</TableCell>
<TableCell size='small'>Participants</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Array.from(channels.values()).map(({ id, peers = [] }) => {
return (
<TableRow key={id} size='small'>
<TableCell monospace>
{id}
</TableCell>
<TableCell monospace>
{peers.length}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};

View File

@ -2,52 +2,184 @@
// Copyright 2020 DXOS.org
//
import React, { useContext } from 'react';
import React, { useContext, useRef, useEffect, useState, useCallback } from 'react';
import { useQuery } from '@apollo/react-hooks';
import useComponentSize from '@rehooks/component-size';
import moment from 'moment';
import Grid from '@material-ui/core/Grid';
import TableBody from '@material-ui/core/TableBody';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import { makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import Collapse from '@material-ui/core/Collapse';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import Table from '../../../components/Table';
import TableCell from '../../../components/TableCell';
import NetworkGraph from '../../../components/NetworkGraph';
import SIGNAL_STATUS from '../../../gql/signal_status.graphql';
import { ConsoleContext, useQueryStatusReducer } from '../../../hooks';
const SignalServers = () => {
const { config } = useContext(ConsoleContext);
const data = useQueryStatusReducer(useQuery(SIGNAL_STATUS, { pollInterval: config.api.intervalQuery }));
if (!data) {
return null;
}
const buildDataGraph = (rootId, prevGraph, nodes) => {
const newGraph = { nodes: [], links: [] };
const { json: { signals = [] } } = data.signal_status;
const rootNode = nodes.find(n => n.id === rootId);
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Signal Server</TableCell>
</TableRow>
</TableHead>
<TableBody>
{signals.map((signal) => {
return (
<TableRow key={signal} size='small'>
<TableCell monospace>
{signal}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
nodes.forEach(node => {
let type = 'detach';
if (rootId === node.id) {
type = 'root';
} else {
const isAdjacent = rootNode.connections.find(conn => conn.target === node.id) || node.connections.find(conn => conn.target === rootId);
if (isAdjacent) {
type = 'adjacent';
}
}
const oldNode = prevGraph.nodes.find(n => n.id === node.id) || {};
const newNode = { ...oldNode, id: node.id, label: node.id.slice(0, 6), type, data: node };
if (type === 'root') {
newNode.fx = 0;
newNode.fy = 0;
}
newGraph.nodes.push(newNode);
});
nodes.forEach(node => {
node.connections.forEach(conn => {
newGraph.links.push({ id: conn.id, source: node.id, target: conn.target });
});
});
return newGraph;
};
const useDataGraph = (response) => {
const [dataGraph, setDataGraph] = useState({ updatedAt: 0, nodes: [], links: [] });
useEffect(() => {
if (!response) return;
const { id: rootId, nodes = [] } = response.signal_status;
const updatedAt = moment(response.signal_status.updatedAt).valueOf();
if (dataGraph.updatedAt >= updatedAt) return;
const graph = buildDataGraph(rootId, dataGraph, nodes);
setDataGraph({
updatedAt,
...graph
});
}, [response && response.signal_status.updatedAt]);
return dataGraph;
};
const useRowStyles = makeStyles({
root: {
'& > *': {
borderBottom: 'unset'
}
}
});
function Row (props) {
const { row } = props;
const classes = useRowStyles();
const system = row.kubeStatus.system;
return (
<>
<TableRow className={classes.root}>
<TableCell component='th' scope='row'>
{row.id}
</TableCell>
<TableCell align='right'>{row.signal.topics.reduce((prev, curr) => prev + curr.peers.length, 0)}</TableCell>
<TableCell align='right'>{system?.version || '-'}</TableCell>
<TableCell align='right'>{system?.nodejs?.version || '-'}</TableCell>
<TableCell align='right'>{system?.memory?.used || '-'}</TableCell>
<TableCell align='right'>{system?.memory?.total || '-'}</TableCell>
<TableCell align='right'>{system?.time?.up ? moment(system?.time?.up).format('lll') : '-'}</TableCell>
</TableRow>
</>
);
}
function SignalServers () {
const { config } = useContext(ConsoleContext);
const response = useQueryStatusReducer(useQuery(SIGNAL_STATUS, { fetchPolicy: 'no-cache', pollInterval: config.api.pollInterval, context: { api: 'signal' } }));
const data = useDataGraph(response);
const sizeRef = useRef(null);
const { width, height } = useComponentSize(sizeRef);
const [open, setOpen] = useState(null);
const handleOpen = useCallback(
(id) => {
if (open && open === id) {
setOpen(null);
} else {
setOpen(id);
}
},
[open]
);
return (
<Grid container spacing={0} direction='column' alignItems='stretch' ref={sizeRef}>
<Grid item xs>
<NetworkGraph
width={width}
height={height / 2}
graph={data}
onTooltip={(node) => {
return (
<>
<strong>WebRTC Peers:</strong> {node.signal.topics.reduce((prev, curr) => prev + curr.peers.length, 0)}
<br />
{node.kubeStatus.services.map((service) => {
return <span key={service.name}><strong>{service.name}:</strong> {service.status}<br /></span>;
})}
</>);
}}
/>
</Grid>
<Grid item xs>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Signal</TableCell>
<TableCell align='right'>Peers (WebRTC)</TableCell>
<TableCell align='right'>Kube version</TableCell>
<TableCell align='right'>Node.JS version</TableCell>
<TableCell align='right'>Memory usage</TableCell>
<TableCell align='right'>Memory total</TableCell>
<TableCell align='right'>Uptime</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data && data.nodes.map(({ data }) => {
return <Row key={data.id} row={data} open={open && open === data.id} setOpen={handleOpen} />;
})}
</TableBody>
</Table>
</TableContainer>
</Grid>
</Grid>
);
}
export default SignalServers;

View File

@ -3,8 +3,41 @@
#
query {
signal_status @client {
timestamp
json
signal_status: status {
id
updatedAt,
nodes {
id
kubeStatus {
system {
memory {
total
used
}
time {
up
}
nodejs {
version
}
}
services {
name
status
}
}
connections {
id
target
}
signal {
topics {
id
peers
}
}
}
}
}

View File

@ -1,7 +1,7 @@
{
"build": {
"name": "@dxos/console-app",
"buildDate": "2020-08-27T19:33:06.925Z",
"version": "1.1.0-beta.1"
"buildDate": "2020-10-07T16:33:05.270Z",
"version": "1.1.0-beta.6"
}
}

View File

@ -29,7 +29,7 @@ services:
webui: 'https://kube.local/dxos/wns/console'
signal:
server: 'wss://kube.local/dxos/signal'
server: 'wss://kube.local/dxos/signal/api'
api: 'https://kube.local/dxos/signal'
ipfs:

View File

@ -30,7 +30,7 @@ services:
signal:
server: 'wss://apollo1.kube.moon.dxos.network/dxos/signal'
api: 'https://apollo1.kube.moon.dxos.network/dxos/signal'
api: 'https://apollo1.kube.moon.dxos.network/dxos/signal/api'
ipfs:
server: 'https://apollo1.kube.moon.dxos.network/dxos/ipfs/api'

View File

@ -30,7 +30,7 @@ services:
signal:
server: 'ws://127.0.0.1:4000'
api: 'http://127.0.0.1:4000'
api: 'http://127.0.0.1:4000/api'
ipfs:
server: 'http://127.0.0.1:5001'

1888
yarn.lock

File diff suppressed because it is too large Load Diff