JimmyMow
7 years ago
committed by
GitHub
39 changed files with 1725 additions and 117 deletions
@ -0,0 +1,292 @@ |
|||||
|
import React, { Component } from 'react' |
||||
|
import PropTypes from 'prop-types' |
||||
|
import * as d3Force from 'd3-force'; |
||||
|
import * as d3Selection from 'd3-selection'; |
||||
|
import * as d3Zoom from 'd3-zoom'; |
||||
|
const d3 = Object.assign({}, d3Force, d3Selection, d3Zoom) |
||||
|
import styles from './CanvasNetworkGraph.scss' |
||||
|
|
||||
|
function generateSimulationData(nodes, edges) { |
||||
|
const resNodes = nodes.map(node => Object.assign(node, { id: node.pub_key })) |
||||
|
const resEdges = edges.map(node => Object.assign(node, { source: node.node1_pub, target: node.node2_pub })) |
||||
|
|
||||
|
return { |
||||
|
nodes: resNodes, |
||||
|
links: resEdges |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class CanvasNetworkGraph extends Component { |
||||
|
constructor(props) { |
||||
|
super(props) |
||||
|
|
||||
|
this.state = { |
||||
|
width: 800, |
||||
|
height: 800, |
||||
|
|
||||
|
simulation: {}, |
||||
|
simulationData: { |
||||
|
nodes: [], |
||||
|
links: [] |
||||
|
}, |
||||
|
|
||||
|
svgLoaded: false |
||||
|
} |
||||
|
|
||||
|
this.startSimulation = this.startSimulation.bind(this) |
||||
|
this.zoomActions = this.zoomActions.bind(this) |
||||
|
this.ticked = this.ticked.bind(this) |
||||
|
this.restart = this.restart.bind(this) |
||||
|
} |
||||
|
|
||||
|
componentWillReceiveProps(nextProps) { |
||||
|
const { network } = nextProps |
||||
|
const { simulationData: { nodes, links } } = this.state |
||||
|
|
||||
|
const simulationDataEmpty = !nodes.length && !links.length |
||||
|
const networkDataLoaded = network.nodes.length || network.edges.length |
||||
|
|
||||
|
// if the simulationData is empty and we have network data
|
||||
|
if (simulationDataEmpty && networkDataLoaded) { |
||||
|
this.setState({ |
||||
|
simulationData: generateSimulationData(network.nodes, network.edges) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
componentDidMount() { |
||||
|
// wait for the svg to be in the DOM before we start the simulation
|
||||
|
const svgInterval = setInterval(() => { |
||||
|
if (document.getElementById('mapContainer')) { |
||||
|
d3.select('#mapContainer') |
||||
|
.append('svg') |
||||
|
.attr('id', 'map') |
||||
|
.attr('width', 800) |
||||
|
.attr('height', 800) |
||||
|
|
||||
|
this.startSimulation() |
||||
|
|
||||
|
clearInterval(svgInterval) |
||||
|
} |
||||
|
}, 1000) |
||||
|
} |
||||
|
|
||||
|
componentDidUpdate(prevProps) { |
||||
|
const { |
||||
|
network: { nodes, edges }, |
||||
|
selectedPeerPubkeys, |
||||
|
selectedChannelIds, |
||||
|
currentRouteChanIds |
||||
|
} = this.props |
||||
|
|
||||
|
const prevNodes = prevProps.network.nodes |
||||
|
const prevEdges = prevProps.network.edges |
||||
|
|
||||
|
// update the simulationData only if the nodes or edges have changed
|
||||
|
if (prevNodes.length !== nodes.length || prevEdges.length !== edges.length) { |
||||
|
this.setState({ |
||||
|
simulationData: generateSimulationData(nodes, edges) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
if (prevProps.selectedPeerPubkeys.length !== selectedPeerPubkeys.length) { |
||||
|
this.updateSelectedPeers() |
||||
|
} |
||||
|
|
||||
|
if (prevProps.selectedChannelIds.length !== selectedChannelIds.length) { |
||||
|
this.updateSelectedChannels() |
||||
|
} |
||||
|
|
||||
|
if (prevProps.currentRouteChanIds.length !== currentRouteChanIds.length) { |
||||
|
this.renderSelectedRoute() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
componentWillUnmount() { |
||||
|
d3.select('#map') |
||||
|
.remove() |
||||
|
} |
||||
|
|
||||
|
updateSelectedPeers() { |
||||
|
const { selectedPeerPubkeys } = this.props |
||||
|
|
||||
|
// remove active class
|
||||
|
d3.selectAll('.active-peer') |
||||
|
.each(function () { |
||||
|
d3.select(this).classed('active-peer', false) |
||||
|
}) |
||||
|
|
||||
|
// add active class to all selected peers
|
||||
|
selectedPeerPubkeys.forEach((pubkey) => { |
||||
|
d3.select(`#node-${pubkey}`).classed('active-peer', true) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
updateSelectedChannels() { |
||||
|
const { selectedChannelIds } = this.props |
||||
|
|
||||
|
// remove active class
|
||||
|
d3.selectAll('.active-channel') |
||||
|
.each(function () { |
||||
|
d3.select(this).classed('active-channel', false) |
||||
|
}) |
||||
|
|
||||
|
// add active class to all selected peers
|
||||
|
selectedChannelIds.forEach((chanid) => { |
||||
|
d3.select(`#link-${chanid}`).classed('active-channel', true) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
renderSelectedRoute() { |
||||
|
const { currentRouteChanIds } = this.props |
||||
|
|
||||
|
// remove all route animations before rendering new ones
|
||||
|
d3.selectAll('.animated-route-circle') |
||||
|
.each(function () { |
||||
|
d3.select(this).remove() |
||||
|
}) |
||||
|
|
||||
|
currentRouteChanIds.forEach((chanId) => { |
||||
|
const link = document.getElementById(`link-${chanId}`) |
||||
|
|
||||
|
if (!link) { return } |
||||
|
const x1 = link.x1.baseVal.value |
||||
|
const x2 = link.x2.baseVal.value |
||||
|
const y1 = link.y1.baseVal.value |
||||
|
const y2 = link.y2.baseVal.value |
||||
|
|
||||
|
// create the circle that represent btc traveling through a channel
|
||||
|
this.g |
||||
|
.append('circle') |
||||
|
.attr('id', `circle-${chanId}`) |
||||
|
.attr('class', 'animated-route-circle') |
||||
|
.attr('r', 50) |
||||
|
.attr('cx', x1) |
||||
|
.attr('cy', y1) |
||||
|
.attr('fill', '#FFDC53') |
||||
|
|
||||
|
// we want the animation to repeat back and forth, this function executes that visually
|
||||
|
const repeat = () => { |
||||
|
d3.select(`#circle-${chanId}`) |
||||
|
.transition() |
||||
|
.attr('cx', x2) |
||||
|
.attr('cy', y2) |
||||
|
.duration(1000) |
||||
|
.transition() |
||||
|
.duration(1000) |
||||
|
.attr('cx', x1) |
||||
|
.attr('cy', y1) |
||||
|
.on('end', repeat) |
||||
|
} |
||||
|
|
||||
|
// call repeat to animate the circle
|
||||
|
repeat() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
startSimulation() { |
||||
|
const { simulationData: { nodes, links } } = this.state |
||||
|
|
||||
|
// grab the svg el along with the attributes
|
||||
|
const svg = d3.select('#map') |
||||
|
const width = +svg.attr('width') |
||||
|
const height = +svg.attr('height') |
||||
|
|
||||
|
this.g = svg.append('g').attr('transform', `translate(${width / 2},${height / 2})`) |
||||
|
this.link = this.g.append('g').attr('stroke', 'white').attr('stroke-width', 1.5).selectAll('.link') |
||||
|
this.node = this.g.append('g').attr('stroke', 'silver').attr('stroke-width', 1.5).selectAll('.node') |
||||
|
|
||||
|
this.simulation = d3.forceSimulation(nodes) |
||||
|
.force('charge', d3.forceManyBody().strength(-750)) |
||||
|
.force('link', d3.forceLink(links).id(d => d.pub_key).distance(500)) |
||||
|
.force('collide', d3.forceCollide(300)) |
||||
|
.on('tick', this.ticked) |
||||
|
.on('end', () => { |
||||
|
this.setState({ svgLoaded: true }) |
||||
|
}) |
||||
|
// zoom
|
||||
|
const zoom_handler = d3.zoom().on('zoom', this.zoomActions) |
||||
|
zoom_handler(svg) |
||||
|
|
||||
|
this.restart() |
||||
|
} |
||||
|
|
||||
|
zoomActions() { |
||||
|
this.g.attr('transform', d3Selection.event.transform) |
||||
|
} |
||||
|
|
||||
|
ticked() { |
||||
|
this.node.attr('cx', d => d.x) |
||||
|
.attr('cy', d => d.y) |
||||
|
|
||||
|
this.link.attr('x1', d => d.source.x) |
||||
|
.attr('y1', d => d.source.y) |
||||
|
.attr('x2', d => d.target.x) |
||||
|
.attr('y2', d => d.target.y) |
||||
|
} |
||||
|
|
||||
|
restart() { |
||||
|
const { identity_pubkey } = this.props |
||||
|
const { simulationData: { nodes, links } } = this.state |
||||
|
|
||||
|
// Apply the general update pattern to the nodes.
|
||||
|
this.node = this.node.data(nodes, d => d.pub_key) |
||||
|
this.node.exit().remove() |
||||
|
this.node = this.node.enter() |
||||
|
.append('circle') |
||||
|
.attr('stroke', () => 'silver') |
||||
|
.attr('fill', d => d.pub_key === identity_pubkey ? '#FFF' : '#353535') |
||||
|
.attr('r', () => 100) |
||||
|
.attr('id', d => `node-${d.pub_key}`) |
||||
|
.attr('class', 'network-node') |
||||
|
.merge(this.node) |
||||
|
|
||||
|
// Apply the general update pattern to the links.
|
||||
|
this.link = this.link.data(links, d => `${d.source.id}-${d.target.id}`) |
||||
|
this.link.exit().remove() |
||||
|
this.link = |
||||
|
this.link.enter() |
||||
|
.append('line') |
||||
|
.attr('id', d => `link-${d.channel_id}`) |
||||
|
.attr('class', 'network-link') |
||||
|
.merge(this.link) |
||||
|
|
||||
|
// Update and restart the simulation.
|
||||
|
this.simulation.nodes(nodes) |
||||
|
this.simulation.force('link').links(links) |
||||
|
this.simulation.restart() |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
const { svgLoaded } = this.state |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.mapContainer} id='mapContainer'> |
||||
|
{ |
||||
|
!svgLoaded && |
||||
|
<div className={styles.loadingContainer}> |
||||
|
<div className={styles.loadingWrap}> |
||||
|
<div className={styles.loader} /> |
||||
|
<div className={styles.loaderbefore} /> |
||||
|
<div className={styles.circular} /> |
||||
|
<div className={`${styles.circular} ${styles.another}`} /> |
||||
|
<div className={styles.text}>loading</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
CanvasNetworkGraph.propTypes = { |
||||
|
identity_pubkey: PropTypes.string.isRequired, |
||||
|
|
||||
|
network: PropTypes.object.isRequired, |
||||
|
|
||||
|
selectedPeerPubkeys: PropTypes.array.isRequired, |
||||
|
selectedChannelIds: PropTypes.array.isRequired, |
||||
|
currentRouteChanIds: PropTypes.array.isRequired |
||||
|
} |
||||
|
|
||||
|
export default CanvasNetworkGraph |
@ -0,0 +1,156 @@ |
|||||
|
@import '../../variables.scss'; |
||||
|
|
||||
|
@keyframes fadein { |
||||
|
0% { background: $white; } |
||||
|
50% { background: lighten($secondary, 50%); } |
||||
|
100% { background: $secondary; animation-fill-mode:forwards; } |
||||
|
} |
||||
|
|
||||
|
.mapContainer { |
||||
|
position: relative; |
||||
|
display: inline-block; |
||||
|
width: 70%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.loadingContainer { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
background: $secondary; |
||||
|
} |
||||
|
|
||||
|
.loadingWrap { |
||||
|
position: relative; |
||||
|
top: calc(50% - 150px); |
||||
|
width: 150px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
.loader { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
z-index: 10; |
||||
|
width: 50px; |
||||
|
height: 50px; |
||||
|
border: 15px solid; |
||||
|
border-radius: 50%; |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
animation: loadEr 3s infinite; |
||||
|
} |
||||
|
|
||||
|
@keyframes loadEr { |
||||
|
0% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
|
||||
|
} |
||||
|
10.4% { |
||||
|
border-top-color: rgba(44,44,44,0.5); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
} |
||||
|
20.8% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
} |
||||
|
31.2% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0.5); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
} |
||||
|
41.6% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
transform: rotate(40deg); |
||||
|
} |
||||
|
52% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0.5); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
} |
||||
|
62.4% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0); |
||||
|
} |
||||
|
72.8% { |
||||
|
border-top-color: rgba(44,44,44,0); |
||||
|
border-right-color: rgba(55,55,55,0); |
||||
|
border-bottom-color: rgba(66,66,66,0); |
||||
|
border-left-color: rgba(33,33,33,0.5); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.loaderbefore { |
||||
|
width: 50px; |
||||
|
height:50px; |
||||
|
border: 15px solid #ddd; |
||||
|
border-radius: 50%; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
z-index: 9; |
||||
|
} |
||||
|
|
||||
|
.circular { |
||||
|
position: absolute; |
||||
|
top: -15px; |
||||
|
left: -15px; |
||||
|
width: 70px; |
||||
|
height: 70px; |
||||
|
border: 20px solid; |
||||
|
border-radius: 50%; |
||||
|
border-top-color: #333; |
||||
|
border-left-color: #fff; |
||||
|
border-bottom-color: #333; |
||||
|
border-right-color: #fff; |
||||
|
opacity: 0.2; |
||||
|
animation: poof 5s infinite; |
||||
|
} |
||||
|
@keyframes poof { |
||||
|
0% {transform: scale(1,1) rotate(0deg); opacity: 0.2;} |
||||
|
50% {transform: scale(4,4) rotate(360deg); opacity: 0;} |
||||
|
} |
||||
|
.another { |
||||
|
opacity: 0.1; |
||||
|
transform: rotate(90deg); |
||||
|
animation: poofity 5s infinite; |
||||
|
animation-delay: 1s; |
||||
|
} |
||||
|
@keyframes poofity { |
||||
|
0% {transform: scale(1,1) rotate(90deg); opacity: 0.1;} |
||||
|
50% {transform: scale(4,4) rotate(-360deg); opacity: 0;} |
||||
|
} |
||||
|
|
||||
|
.text { |
||||
|
position: absolute; |
||||
|
top: 95px; |
||||
|
left: 8px; |
||||
|
font-family: Arial; |
||||
|
text-transform: uppercase; |
||||
|
color: #888; |
||||
|
animation: opaa 10s infinite; |
||||
|
} |
||||
|
@keyframes opaa { |
||||
|
0% {opacity: 1;} |
||||
|
10% {opacity: 0.5} |
||||
|
15% {opacity: 1;} |
||||
|
30% {opacity: 1;} |
||||
|
65% {opacity: 0.3;} |
||||
|
90% {opacity: 0.8;} |
||||
|
} |
@ -0,0 +1,46 @@ |
|||||
|
import { shell } from 'electron' |
||||
|
import React from 'react' |
||||
|
import PropTypes from 'prop-types' |
||||
|
import { btc } from 'utils' |
||||
|
import styles from './ChannelsList.scss' |
||||
|
|
||||
|
const ChannelsList = ({ channels, updateSelectedChannels, selectedChannelIds }) => ( |
||||
|
<ul className={styles.channels}> |
||||
|
{ |
||||
|
channels.map(channel => |
||||
|
<li key={channel.chan_id} className={styles.channel} onClick={() => updateSelectedChannels(channel)}> |
||||
|
<span className={`${styles.dot} ${selectedChannelIds.includes(channel.chan_id) && styles.active}`} /> |
||||
|
|
||||
|
<header> |
||||
|
<h1>Capacity: {btc.satoshisToBtc(channel.capacity)}</h1> |
||||
|
<span onClick={() => shell.openExternal(`https://testnet.smartbit.com.au/tx/${channel.channel_point.split(':')[0]}`)}>Channel Point</span> |
||||
|
</header> |
||||
|
|
||||
|
<section> |
||||
|
<h4>Remote Pubkey:</h4> |
||||
|
<p>{channel.remote_pubkey.substring(0, Math.min(30, channel.remote_pubkey.length))}...</p> |
||||
|
</section> |
||||
|
|
||||
|
<section className={styles.funds}> |
||||
|
<div> |
||||
|
<h4>Sent:</h4> |
||||
|
<p>{btc.satoshisToBtc(channel.total_satoshis_sent)} BTC</p> |
||||
|
</div> |
||||
|
<div> |
||||
|
<h4>Received:</h4> |
||||
|
<p>{btc.satoshisToBtc(channel.total_satoshis_received)} BTC</p> |
||||
|
</div> |
||||
|
</section> |
||||
|
</li> |
||||
|
) |
||||
|
} |
||||
|
</ul> |
||||
|
) |
||||
|
|
||||
|
ChannelsList.propTypes = { |
||||
|
channels: PropTypes.array.isRequired, |
||||
|
updateSelectedChannels: PropTypes.func.isRequired, |
||||
|
selectedChannelIds: PropTypes.array.isRequired |
||||
|
} |
||||
|
|
||||
|
export default ChannelsList |
@ -0,0 +1,78 @@ |
|||||
|
@import '../../variables.scss'; |
||||
|
|
||||
|
.channels { |
||||
|
color: $white; |
||||
|
margin-top: 50px; |
||||
|
} |
||||
|
|
||||
|
.channel { |
||||
|
position: relative; |
||||
|
margin: 20px 0; |
||||
|
padding: 10px 40px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.25s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: darken(#353535, 10%); |
||||
|
|
||||
|
.dot { |
||||
|
background: #88D4A2; |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.dot { |
||||
|
position: absolute; |
||||
|
top: calc(15% - 10px); |
||||
|
left: 5%; |
||||
|
width: 10px; |
||||
|
height: 10px; |
||||
|
border: 1px solid #979797; |
||||
|
border-radius: 50%; |
||||
|
|
||||
|
&.active { |
||||
|
background: #88D4A2; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
header { |
||||
|
margin-bottom: 10px; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
|
||||
|
h1 { |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-size: 10px; |
||||
|
text-decoration: underline; |
||||
|
transition: all 0.25s; |
||||
|
|
||||
|
&:hover { |
||||
|
color: #88D4A2; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
section { |
||||
|
margin: 10px 0; |
||||
|
|
||||
|
h4 { |
||||
|
font-weight: bold; |
||||
|
text-transform: uppercase; |
||||
|
font-size: 10px; |
||||
|
margin-bottom: 5px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.funds { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
margin-top: 20px; |
||||
|
} |
||||
|
} |
@ -0,0 +1,25 @@ |
|||||
|
import React from 'react' |
||||
|
import PropTypes from 'prop-types' |
||||
|
import styles from './PeersList.scss' |
||||
|
|
||||
|
const PeersList = ({ peers, updateSelectedPeers, selectedPeerPubkeys }) => ( |
||||
|
<ul className={styles.peers}> |
||||
|
{ |
||||
|
peers.map(peer => |
||||
|
<li key={peer.peer_id} className={styles.peer} onClick={() => updateSelectedPeers(peer)}> |
||||
|
<span className={`${styles.dot} ${selectedPeerPubkeys.includes(peer.pub_key) && styles.active}`} /> |
||||
|
<h1>{peer.address}</h1> |
||||
|
<h4>{peer.pub_key}</h4> |
||||
|
</li> |
||||
|
) |
||||
|
} |
||||
|
</ul> |
||||
|
) |
||||
|
|
||||
|
PeersList.propTypes = { |
||||
|
peers: PropTypes.array.isRequired, |
||||
|
updateSelectedPeers: PropTypes.func.isRequired, |
||||
|
selectedPeerPubkeys: PropTypes.array.isRequired |
||||
|
} |
||||
|
|
||||
|
export default PeersList |
@ -0,0 +1,46 @@ |
|||||
|
@import '../../variables.scss'; |
||||
|
|
||||
|
.peers { |
||||
|
color: $white; |
||||
|
margin-top: 50px; |
||||
|
} |
||||
|
|
||||
|
.peer { |
||||
|
position: relative; |
||||
|
margin: 20px 0; |
||||
|
padding: 10px 40px; |
||||
|
cursor: pointer; |
||||
|
transition: all 0.25s; |
||||
|
|
||||
|
&:hover { |
||||
|
background: darken(#353535, 10%); |
||||
|
|
||||
|
.dot { |
||||
|
background: #5589F3; |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.dot { |
||||
|
position: absolute; |
||||
|
top: calc(50% - 10px); |
||||
|
left: 5%; |
||||
|
width: 10px; |
||||
|
height: 10px; |
||||
|
border: 1px solid #979797; |
||||
|
border-radius: 50%; |
||||
|
|
||||
|
&.active { |
||||
|
background: #5589F3; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
h1 { |
||||
|
font-size: 16px; |
||||
|
margin-bottom: 10px; |
||||
|
} |
||||
|
|
||||
|
h4 { |
||||
|
font-size: 8px; |
||||
|
} |
||||
|
} |
@ -0,0 +1,61 @@ |
|||||
|
import React from 'react' |
||||
|
import PropTypes from 'prop-types' |
||||
|
import { btc } from 'utils' |
||||
|
import styles from './TransactionForm.scss' |
||||
|
|
||||
|
const TransactionForm = ({ updatePayReq, pay_req, loadingRoutes, payReqRoutes, setCurrentRoute, currentRoute }) => ( |
||||
|
<div className={styles.transactionForm}> |
||||
|
<div className={styles.form}> |
||||
|
<input |
||||
|
className={styles.transactionInput} |
||||
|
placeholder='Payment request...' |
||||
|
value={pay_req} |
||||
|
onChange={event => updatePayReq(event.target.value)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{ |
||||
|
loadingRoutes && |
||||
|
<div className={styles.loading}> |
||||
|
<div className={styles.spinner} /> |
||||
|
<h1>calculating all routes...</h1> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<ul className={styles.routes}> |
||||
|
{ |
||||
|
payReqRoutes.map((route, index) => |
||||
|
<li className={`${styles.route} ${currentRoute == route && styles.active}`} key={index} onClick={() => setCurrentRoute(route)}> |
||||
|
<header> |
||||
|
<h1>Route #{index + 1}</h1> |
||||
|
<span>Hops: {route.hops.length}</span> |
||||
|
</header> |
||||
|
|
||||
|
<div className={styles.data}> |
||||
|
<section> |
||||
|
<h4>Amount</h4> |
||||
|
<span>{btc.satoshisToBtc(route.total_amt)} BTC</span> |
||||
|
</section> |
||||
|
|
||||
|
<section> |
||||
|
<h4>Fees</h4> |
||||
|
<span>{btc.satoshisToBtc(route.total_fees)} BTC</span> |
||||
|
</section> |
||||
|
</div> |
||||
|
</li> |
||||
|
) |
||||
|
} |
||||
|
</ul> |
||||
|
</div> |
||||
|
) |
||||
|
|
||||
|
TransactionForm.propTypes = { |
||||
|
updatePayReq: PropTypes.func.isRequired, |
||||
|
pay_req: PropTypes.string.isRequired, |
||||
|
loadingRoutes: PropTypes.bool.isRequired, |
||||
|
payReqRoutes: PropTypes.array.isRequired, |
||||
|
setCurrentRoute: PropTypes.func.isRequired, |
||||
|
currentRoute: PropTypes.object.isRequired |
||||
|
} |
||||
|
|
||||
|
export default TransactionForm |
@ -0,0 +1,125 @@ |
|||||
|
@import '../../variables.scss'; |
||||
|
|
||||
|
@-webkit-keyframes animation-rotate { |
||||
|
100% { |
||||
|
-webkit-transform: rotate(360deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@-moz-keyframes animation-rotate { |
||||
|
100% { |
||||
|
-moz-transform: rotate(360deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@-o-keyframes animation-rotate { |
||||
|
100% { |
||||
|
-o-transform: rotate(360deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes animation-rotate { |
||||
|
100% { |
||||
|
transform: rotate(360deg); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.spinner { |
||||
|
border: 1px solid rgba(255, 220, 83, 0.1); |
||||
|
border-left-color: rgba(255, 220, 83, 0.4); |
||||
|
-webkit-border-radius: 999px; |
||||
|
-moz-border-radius: 999px; |
||||
|
border-radius: 999px; |
||||
|
} |
||||
|
|
||||
|
.spinner { |
||||
|
margin: 0 auto; |
||||
|
height: 100px; |
||||
|
width: 100px; |
||||
|
-webkit-animation: animation-rotate 1000ms linear infinite; |
||||
|
-moz-animation: animation-rotate 1000ms linear infinite; |
||||
|
-o-animation: animation-rotate 1000ms linear infinite; |
||||
|
animation: animation-rotate 1000ms linear infinite; |
||||
|
} |
||||
|
|
||||
|
.loading { |
||||
|
margin-top: 40px; |
||||
|
|
||||
|
h1 { |
||||
|
text-align: center; |
||||
|
margin-top: 25px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.transactionForm { |
||||
|
color: $white; |
||||
|
margin-top: 50px; |
||||
|
|
||||
|
.form { |
||||
|
padding: 0 20px; |
||||
|
} |
||||
|
|
||||
|
.transactionInput { |
||||
|
outline: 0; |
||||
|
border: 0; |
||||
|
border-bottom: 1px solid $secondary; |
||||
|
color: $secondary; |
||||
|
background: transparent; |
||||
|
padding: 5px; |
||||
|
width: 100%; |
||||
|
font-size: 14px; |
||||
|
color: $white; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.routes { |
||||
|
margin-top: 40px; |
||||
|
} |
||||
|
|
||||
|
.route { |
||||
|
margin: 20px 0; |
||||
|
padding: 20px; |
||||
|
cursor: pointer; |
||||
|
|
||||
|
&:hover { |
||||
|
background: darken(#353535, 10%); |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
background: darken(#353535, 10%); |
||||
|
} |
||||
|
|
||||
|
header { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
margin-bottom: 20px; |
||||
|
|
||||
|
h1 { |
||||
|
font-size: 16px; |
||||
|
font-weight: bold; |
||||
|
} |
||||
|
|
||||
|
span { |
||||
|
font-weight: bold; |
||||
|
text-transform: uppercase; |
||||
|
font-size: 12px; |
||||
|
letter-spacing: 1.2px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.data { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
|
||||
|
section { |
||||
|
h4 { |
||||
|
font-size: 12px; |
||||
|
font-weight: bold; |
||||
|
margin-bottom: 5px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,42 +0,0 @@ |
|||||
import zbase32 from 'zbase32' |
|
||||
|
|
||||
function convertBigEndianBufferToLong(longBuffer) { |
|
||||
let longValue = 0 |
|
||||
const byteArray = Buffer.from(longBuffer).swap64() |
|
||||
|
|
||||
for (let i = byteArray.length - 1; i >= 0; i -= 1) { |
|
||||
longValue = (longValue * 256) + byteArray[i] |
|
||||
} |
|
||||
|
|
||||
return longValue |
|
||||
} |
|
||||
|
|
||||
export function decodeInvoice(payreq) { |
|
||||
const payreqBase32 = zbase32.decode(payreq) |
|
||||
|
|
||||
const bufferHexRotated = Buffer.from(payreqBase32).toString('hex') |
|
||||
const bufferHex = bufferHexRotated.substr(bufferHexRotated.length - 1, bufferHexRotated.length) |
|
||||
+ bufferHexRotated.substr(0, bufferHexRotated.length - 1) |
|
||||
const buffer = Buffer.from(bufferHex, 'hex') |
|
||||
|
|
||||
const pubkeyBuffer = buffer.slice(0, 33) |
|
||||
const pubkey = pubkeyBuffer.toString('hex') |
|
||||
|
|
||||
const paymentHashBuffer = buffer.slice(33, 65) |
|
||||
const paymentHashHex = paymentHashBuffer.toString('hex') |
|
||||
|
|
||||
const valueBuffer = buffer.slice(65, 73) |
|
||||
|
|
||||
const amount = convertBigEndianBufferToLong(valueBuffer) |
|
||||
|
|
||||
return { |
|
||||
payreq, |
|
||||
pubkey, |
|
||||
amount, |
|
||||
r_hash: paymentHashHex |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default { |
|
||||
decodeInvoice |
|
||||
} |
|
@ -0,0 +1,155 @@ |
|||||
|
import React, { Component } from 'react' |
||||
|
import PropTypes from 'prop-types' |
||||
|
|
||||
|
import CanvasNetworkGraph from 'components/Network/CanvasNetworkGraph' |
||||
|
import PeersList from 'components/Network/PeersList' |
||||
|
import ChannelsList from 'components/Network/ChannelsList' |
||||
|
import TransactionForm from 'components/Network/TransactionForm' |
||||
|
|
||||
|
import styles from './Network.scss' |
||||
|
|
||||
|
class Network extends Component { |
||||
|
componentWillMount() { |
||||
|
const { fetchDescribeNetwork, fetchPeers, fetchChannels } = this.props |
||||
|
|
||||
|
fetchPeers() |
||||
|
fetchChannels() |
||||
|
fetchDescribeNetwork() |
||||
|
} |
||||
|
|
||||
|
componentDidUpdate(prevProps) { |
||||
|
const { payReqIsLn, network: { pay_req }, fetchInvoiceAndQueryRoutes, clearQueryRoutes } = this.props |
||||
|
|
||||
|
// If LN go retrieve invoice details
|
||||
|
if ((prevProps.network.pay_req !== pay_req) && payReqIsLn) { |
||||
|
fetchInvoiceAndQueryRoutes(pay_req) |
||||
|
} |
||||
|
|
||||
|
if (prevProps.payReqIsLn && !payReqIsLn) { |
||||
|
clearQueryRoutes() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
componentWillUnmount() { |
||||
|
const { clearQueryRoutes, resetPayReq, clearSelectedChannels, clearSelectedPeers } = this.props |
||||
|
|
||||
|
clearQueryRoutes() |
||||
|
resetPayReq() |
||||
|
clearSelectedChannels() |
||||
|
clearSelectedPeers() |
||||
|
} |
||||
|
|
||||
|
render() { |
||||
|
const { |
||||
|
setCurrentTab, |
||||
|
updateSelectedPeers, |
||||
|
setCurrentRoute, |
||||
|
|
||||
|
network, |
||||
|
selectedPeerPubkeys, |
||||
|
currentRouteChanIds, |
||||
|
|
||||
|
peers: { peers }, |
||||
|
|
||||
|
activeChannels, |
||||
|
selectedChannelIds, |
||||
|
updateSelectedChannels, |
||||
|
|
||||
|
updatePayReq, |
||||
|
|
||||
|
identity_pubkey |
||||
|
} = this.props |
||||
|
|
||||
|
const renderContent = () => { |
||||
|
switch (network.currentTab) { |
||||
|
case 1: |
||||
|
return <PeersList peers={peers} updateSelectedPeers={updateSelectedPeers} selectedPeerPubkeys={selectedPeerPubkeys} /> |
||||
|
case 2: |
||||
|
return <ChannelsList channels={activeChannels} updateSelectedChannels={updateSelectedChannels} selectedChannelIds={selectedChannelIds} /> |
||||
|
case 3: |
||||
|
return ( |
||||
|
<TransactionForm |
||||
|
updatePayReq={updatePayReq} |
||||
|
pay_req={network.pay_req} |
||||
|
loadingRoutes={network.fetchingInvoiceAndQueryingRoutes} |
||||
|
payReqRoutes={network.payReqRoutes} |
||||
|
setCurrentRoute={setCurrentRoute} |
||||
|
currentRoute={network.currentRoute} |
||||
|
/> |
||||
|
) |
||||
|
default: |
||||
|
return <span /> |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className={styles.container}> |
||||
|
<CanvasNetworkGraph |
||||
|
className={styles.network} |
||||
|
network={network} |
||||
|
identity_pubkey={identity_pubkey} |
||||
|
selectedPeerPubkeys={selectedPeerPubkeys} |
||||
|
selectedChannelIds={selectedChannelIds} |
||||
|
currentRouteChanIds={currentRouteChanIds} |
||||
|
/> |
||||
|
|
||||
|
<section className={styles.toolbox}> |
||||
|
<ul className={styles.tabs}> |
||||
|
<li |
||||
|
className={`${styles.tab} ${styles.peersTab} ${network.currentTab === 1 && styles.active}`} |
||||
|
onClick={() => setCurrentTab(1)} |
||||
|
> |
||||
|
Peers |
||||
|
</li> |
||||
|
<li |
||||
|
className={`${styles.tab} ${styles.channelsTab} ${network.currentTab === 2 && styles.active}`} |
||||
|
onClick={() => setCurrentTab(2)} |
||||
|
> |
||||
|
Channels |
||||
|
</li> |
||||
|
<li |
||||
|
className={`${styles.tab} ${styles.transactionsTab} ${network.currentTab === 3 && styles.active}`} |
||||
|
onClick={() => setCurrentTab(3)} |
||||
|
> |
||||
|
Transactions |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
<div className={styles.content}> |
||||
|
{renderContent()} |
||||
|
</div> |
||||
|
</section> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Network.propTypes = { |
||||
|
fetchDescribeNetwork: PropTypes.func.isRequired, |
||||
|
fetchPeers: PropTypes.func.isRequired, |
||||
|
setCurrentTab: PropTypes.func.isRequired, |
||||
|
fetchChannels: PropTypes.func.isRequired, |
||||
|
fetchInvoiceAndQueryRoutes: PropTypes.func.isRequired, |
||||
|
clearQueryRoutes: PropTypes.func.isRequired, |
||||
|
resetPayReq: PropTypes.func.isRequired, |
||||
|
clearSelectedChannels: PropTypes.func.isRequired, |
||||
|
clearSelectedPeers: PropTypes.func.isRequired, |
||||
|
updateSelectedPeers: PropTypes.func.isRequired, |
||||
|
setCurrentRoute: PropTypes.func.isRequired, |
||||
|
updateSelectedChannels: PropTypes.func.isRequired, |
||||
|
updatePayReq: PropTypes.func.isRequired, |
||||
|
|
||||
|
network: PropTypes.object.isRequired, |
||||
|
peers: PropTypes.object.isRequired, |
||||
|
|
||||
|
selectedPeerPubkeys: PropTypes.array.isRequired, |
||||
|
currentRouteChanIds: PropTypes.array.isRequired, |
||||
|
activeChannels: PropTypes.array.isRequired, |
||||
|
selectedChannelIds: PropTypes.array.isRequired, |
||||
|
|
||||
|
identity_pubkey: PropTypes.string.isRequired, |
||||
|
|
||||
|
payReqIsLn: PropTypes.bool.isRequired |
||||
|
} |
||||
|
|
||||
|
export default Network |
@ -0,0 +1,81 @@ |
|||||
|
@import '../../../variables.scss'; |
||||
|
|
||||
|
@keyframes dash { |
||||
|
to { |
||||
|
stroke-dashoffset: 1000; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes fadein { |
||||
|
0% { background: $white; } |
||||
|
50% { background: lighten($secondary, 50%); } |
||||
|
100% { background: $secondary; animation-fill-mode:forwards; } |
||||
|
} |
||||
|
|
||||
|
.container { |
||||
|
width: 100%; |
||||
|
height: 100vh; |
||||
|
animation: fadein 0.5s; |
||||
|
animation-timing-function:linear; |
||||
|
animation-fill-mode:forwards; |
||||
|
animation-iteration-count: 1; |
||||
|
|
||||
|
line.active { |
||||
|
opacity: 1; |
||||
|
stroke: green; |
||||
|
stroke-width: 5; |
||||
|
stroke-dasharray: 100; |
||||
|
animation: dash 2.5s infinite linear; |
||||
|
} |
||||
|
|
||||
|
circle { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.network, .toolbox { |
||||
|
display: inline-block; |
||||
|
vertical-align: top; |
||||
|
height: 100vh; |
||||
|
} |
||||
|
|
||||
|
.network { |
||||
|
width: 70%; |
||||
|
} |
||||
|
|
||||
|
.toolbox { |
||||
|
width: 30%; |
||||
|
height: 100%; |
||||
|
background: #353535; |
||||
|
overflow-y: scroll; |
||||
|
} |
||||
|
|
||||
|
.tabs { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding-top: 40px; |
||||
|
|
||||
|
.tab { |
||||
|
color: $white; |
||||
|
text-align: center; |
||||
|
cursor: pointer; |
||||
|
width: 100%; |
||||
|
padding: 10px 0; |
||||
|
border-bottom: 1px solid #464646; |
||||
|
transition: all 0.5s; |
||||
|
|
||||
|
&.peersTab:hover, &.peersTab.active { |
||||
|
border-bottom: 1px solid #588CF0; |
||||
|
} |
||||
|
|
||||
|
&.channelsTab:hover, &.channelsTab.active { |
||||
|
border-bottom: 1px solid #88D4A2; |
||||
|
} |
||||
|
|
||||
|
&.transactionsTab:hover, &.transactionsTab.active { |
||||
|
border-bottom: 1px solid #FFDC53; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,62 @@ |
|||||
|
import { withRouter } from 'react-router' |
||||
|
import { connect } from 'react-redux' |
||||
|
|
||||
|
import { |
||||
|
fetchDescribeNetwork, |
||||
|
|
||||
|
setCurrentTab, |
||||
|
|
||||
|
updateSelectedPeers, |
||||
|
clearSelectedPeers, |
||||
|
|
||||
|
updateSelectedChannels, |
||||
|
clearSelectedChannels, |
||||
|
|
||||
|
setCurrentRoute, |
||||
|
|
||||
|
updatePayReq, |
||||
|
resetPayReq, |
||||
|
|
||||
|
fetchInvoiceAndQueryRoutes, |
||||
|
clearQueryRoutes, |
||||
|
|
||||
|
networkSelectors |
||||
|
} from '../../../reducers/network' |
||||
|
import { fetchPeers } from '../../../reducers/peers' |
||||
|
import { fetchChannels, channelsSelectors } from '../../../reducers/channels' |
||||
|
|
||||
|
import Network from '../components/Network' |
||||
|
|
||||
|
const mapDispatchToProps = { |
||||
|
fetchDescribeNetwork, |
||||
|
setCurrentTab, |
||||
|
|
||||
|
updateSelectedPeers, |
||||
|
clearSelectedPeers, |
||||
|
|
||||
|
updatePayReq, |
||||
|
fetchInvoiceAndQueryRoutes, |
||||
|
setCurrentRoute, |
||||
|
clearQueryRoutes, |
||||
|
resetPayReq, |
||||
|
|
||||
|
fetchPeers, |
||||
|
|
||||
|
fetchChannels, |
||||
|
updateSelectedChannels, |
||||
|
clearSelectedChannels |
||||
|
} |
||||
|
|
||||
|
const mapStateToProps = state => ({ |
||||
|
network: state.network, |
||||
|
peers: state.peers, |
||||
|
identity_pubkey: state.info.data.identity_pubkey, |
||||
|
|
||||
|
selectedPeerPubkeys: networkSelectors.selectedPeerPubkeys(state), |
||||
|
selectedChannelIds: networkSelectors.selectedChannelIds(state), |
||||
|
payReqIsLn: networkSelectors.payReqIsLn(state), |
||||
|
currentRouteChanIds: networkSelectors.currentRouteChanIds(state), |
||||
|
activeChannels: channelsSelectors.activeChannels(state) |
||||
|
}) |
||||
|
|
||||
|
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Network)) |
@ -0,0 +1,3 @@ |
|||||
|
import NetworkContainer from './containers/NetworkContainer' |
||||
|
|
||||
|
export default NetworkContainer |
Loading…
Reference in new issue