Categorize nodes by chains

This commit is contained in:
maciejhirsz
2018-07-06 15:08:22 +02:00
parent ae009a7649
commit 9a8e625df6
18 changed files with 578 additions and 218 deletions
+84
View File
@@ -0,0 +1,84 @@
import Chain from './Chain';
import Node from './Node';
import Feed from './Feed';
import FeedSet from './FeedSet';
import { Types, FeedMessage } from '@dotstats/common';
export default class Aggregator {
private readonly chains = new Map<Types.ChainLabel, Chain>();
private readonly feeds = new FeedSet();
constructor() {
setInterval(() => this.timeoutCheck(), 10000);
}
public addNode(node: Node) {
let chain = this.getChain(node.chain);
chain.addNode(node);
}
public addFeed(feed: Feed) {
this.feeds.add(feed);
for (const chain of this.chains.values()) {
feed.sendMessage(Feed.addedChain(chain.label));
}
feed.events.on('subscribe', (label: Types.ChainLabel) => {
const chain = this.chains.get(label);
if (chain) {
chain.addFeed(feed);
feed.sendMessage(Feed.subscribedTo(label));
}
})
feed.events.on('unsubscribe', (label: Types.ChainLabel) => {
const chain = this.chains.get(label);
if (chain) {
chain.removeFeed(feed);
feed.sendMessage(Feed.unsubscribedFrom(label));
}
});
}
private getChain(label: Types.ChainLabel): Chain {
const chain = this.chains.get(label);
if (chain) {
return chain;
} else {
const chain = new Chain(label);
chain.events.on('disconnect', (count: number) => {
if (count !== 0) {
return;
}
chain.events.removeAllListeners();
this.chains.delete(chain.label);
console.log(`Chain: ${label} lost all nodes`);
this.feeds.broadcast(Feed.removedChain(label));
});
this.chains.set(label, chain);
console.log(`New chain: ${label}`);
this.feeds.broadcast(Feed.addedChain(label));
return chain;
}
}
private timeoutCheck() {
const empty: Types.ChainLabel[] = [];
for (const chain of this.chains.values()) {
chain.timeoutCheck();
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import * as EventEmitter from 'events';
import Node from './Node';
import Feed from './Feed';
import FeedSet from './FeedSet';
import { timestamp, Types, FeedMessage } from '@dotstats/common';
export default class Chain {
private nodes = new Set<Node>();
private feeds = new FeedSet();
public readonly events = new EventEmitter();
public readonly label: Types.ChainLabel;
public height = 0 as Types.BlockNumber;
public blockTimestamp = 0 as Types.Timestamp;
constructor(label: Types.ChainLabel) {
this.label = label;
}
public get nodeCount(): number {
return this.nodes.size;
}
public addNode(node: Node) {
console.log(`[${this.label}] new node: ${node.name}`);
this.nodes.add(node);
this.feeds.broadcast(Feed.addedNode(node));
node.events.once('disconnect', () => {
node.events.removeAllListeners();
this.nodes.delete(node);
this.feeds.broadcast(Feed.removedNode(node));
this.events.emit('disconnect', this.nodeCount);
});
node.events.on('block', () => this.updateBlock(node));
node.events.on('stats', () => this.feeds.broadcast(Feed.stats(node)));
}
public addFeed(feed: Feed) {
this.feeds.add(feed);
// TODO: this is a bit unclean, find a better way
feed.chain = this.label;
feed.sendMessage(Feed.timeSync());
feed.sendMessage(Feed.bestBlock(this.height, this.blockTimestamp));
for (const node of this.nodes.values()) {
feed.sendMessage(Feed.addedNode(node));
}
}
public removeFeed(feed: Feed) {
this.feeds.remove(feed);
}
public nodeList(): IterableIterator<Node> {
return this.nodes.values();
}
public timeoutCheck() {
const now = timestamp();
for (const node of this.nodes.values()) {
node.timeoutCheck(now);
}
this.feeds.broadcast(Feed.timeSync());
}
private updateBlock(node: Node) {
if (node.height > this.height) {
this.height = node.height;
this.blockTimestamp = node.blockTimestamp;
this.feeds.broadcast(Feed.bestBlock(this.height, this.blockTimestamp));
console.log(`[${this.label}] New block ${this.height}`);
}
this.feeds.broadcast(Feed.imported(node));
console.log(`[${this.label}] ${node.name} imported ${node.height}, block time: ${node.blockTime / 1000}s, average: ${node.average / 1000}s | latency ${node.latency}`);
}
}
+136
View File
@@ -0,0 +1,136 @@
import * as WebSocket from 'ws';
import * as EventEmitter from 'events';
import Node from './Node';
import { timestamp, Maybe, FeedMessage, Types, idGenerator } from '@dotstats/common';
const nextId = idGenerator<Types.FeedId>();
const { Actions } = FeedMessage;
export default class Feed {
public id: Types.FeedId;
public chain: Maybe<Types.ChainLabel> = null;
public readonly events = new EventEmitter();
private socket: WebSocket;
private messages: Array<FeedMessage.Message> = [];
constructor(socket: WebSocket) {
this.id = nextId();
this.socket = socket;
socket.on('message', (data) => this.handleCommand(data.toString()));
socket.on('error', () => this.disconnect());
socket.on('close', () => this.disconnect());
}
public static bestBlock(height: Types.BlockNumber, ts: Types.Timestamp): FeedMessage.Message {
return {
action: Actions.BestBlock,
payload: [height, ts]
};
}
public static addedNode(node: Node): FeedMessage.Message {
return {
action: Actions.AddedNode,
payload: [node.id, node.nodeDetails(), node.nodeStats(), node.blockDetails()]
};
}
public static removedNode(node: Node): FeedMessage.Message {
return {
action: Actions.RemovedNode,
payload: node.id
};
}
public static imported(node: Node): FeedMessage.Message {
return {
action: Actions.ImportedBlock,
payload: [node.id, node.blockDetails()]
};
}
public static stats(node: Node): FeedMessage.Message {
return {
action: Actions.NodeStats,
payload: [node.id, node.nodeStats()]
};
}
public static timeSync(): FeedMessage.Message {
return {
action: Actions.TimeSync,
payload: timestamp()
};
}
public static addedChain(label: Types.ChainLabel): FeedMessage.Message {
return {
action: Actions.AddedChain,
payload: label
};
}
public static removedChain(label: Types.ChainLabel): FeedMessage.Message {
return {
action: Actions.RemovedChain,
payload: label
}
}
public static subscribedTo(label: Types.ChainLabel): FeedMessage.Message {
return {
action: Actions.SubscribedTo,
payload: label,
}
}
public static unsubscribedFrom(label: Types.ChainLabel): FeedMessage.Message {
return {
action: Actions.UnsubscribedFrom,
payload: label,
}
}
public sendData(data: FeedMessage.Data) {
this.socket.send(data);
}
public sendMessage(message: FeedMessage.Message) {
const queue = this.messages.length === 0;
this.messages.push(message);
if (queue) {
process.nextTick(this.sendMessages);
}
}
private sendMessages = () => {
const data = FeedMessage.serialize(this.messages);
this.messages = [];
this.socket.send(data);
}
private handleCommand(cmd: string) {
if (cmd.startsWith('subscribe:')) {
if (this.chain) {
this.events.emit('unsubscribe', this.chain);
this.chain = null;
}
const label = cmd.substr(10) as Types.ChainLabel;
this.events.emit('subscribe', label);
}
}
private disconnect() {
this.socket.removeAllListeners();
this.socket.close();
this.events.emit('disconnect');
}
}
+55
View File
@@ -0,0 +1,55 @@
import Feed from './Feed';
import { FeedMessage } from '@dotstats/common';
type DisconnectListener = () => void;
export default class FeedSet {
private feeds = new Map<Feed, DisconnectListener>();
private messages: Array<FeedMessage.Message> = [];
public values(): IterableIterator<Feed> {
return this.feeds.keys();
}
public each(fn: (feed: Feed) => void) {
for (const feed of this.values()) {
fn(feed);
}
}
public add(feed: Feed) {
const listener = () => this.remove(feed);
this.feeds.set(feed, listener);
feed.events.once('disconnect', listener);
}
public remove(feed: Feed) {
const listener = this.feeds.get(feed);
if (!listener) {
return;
}
feed.events.removeListener('disconnect', listener);
this.feeds.delete(feed);
}
public broadcast(message: FeedMessage.Message) {
const queue = this.messages.length === 0;
this.messages.push(message);
if (queue) {
process.nextTick(this.sendMessages);
}
}
private sendMessages = () => {
const data = FeedMessage.serialize(this.messages);
this.messages = [];
this.each(feed => feed.sendData(data));
}
}
@@ -8,12 +8,16 @@ const TIMEOUT = (1000 * 60 * 1) as Types.Milliseconds; // 1 minute
const nextId = idGenerator<Types.NodeId>();
export default class Node extends EventEmitter {
export default class Node {
public readonly id: Types.NodeId;
public readonly name: Types.NodeName;
public readonly chain: Types.ChainLabel;
public readonly implementation: Types.NodeImplementation;
public readonly version: Types.NodeVersion;
public readonly events = new EventEmitter();
public lastMessage: Types.Timestamp;
public id: Types.NodeId;
public name: Types.NodeName;
public implementation: Types.NodeImplementation;
public version: Types.NodeVersion;
public config: string;
public best = '' as Types.BlockHash;
public height = 0 as Types.BlockNumber;
@@ -24,28 +28,26 @@ export default class Node extends EventEmitter {
private peers = 0 as Types.PeerCount;
private txcount = 0 as Types.TransactionCount;
private socket: WebSocket;
private readonly socket: WebSocket;
private blockTimes: Array<number> = new Array(BLOCK_TIME_HISTORY);
private lastBlockAt: Maybe<Date> = null;
constructor(
socket: WebSocket,
name: Types.NodeName,
chain: Types.ChainLabel,
config: string,
implentation: Types.NodeImplementation,
version: Types.NodeVersion,
) {
super();
this.lastMessage = timestamp();
this.id = nextId();
this.socket = socket;
this.name = name;
this.chain = chain;
this.config = config;
this.implementation = implentation;
this.version = version;
console.log(`Listening to a new node: ${name}`);
this.lastMessage = timestamp();
this.socket = socket;
socket.on('message', (data) => {
const message = parseMessage(data);
@@ -94,9 +96,9 @@ export default class Node extends EventEmitter {
if (message && message.msg === "system.connected") {
cleanup();
const { name, config, implementation, version } = message;
const { name, chain, config, implementation, version } = message;
resolve(new Node(socket, name, config, implementation, version));
resolve(new Node(socket, name, chain, config, implementation, version));
}
}
@@ -160,7 +162,7 @@ export default class Node extends EventEmitter {
this.socket.removeAllListeners();
this.socket.close();
this.emit('disconnect');
this.events.emit('disconnect');
}
private onSystemInterval(message: SystemInterval) {
@@ -170,7 +172,7 @@ export default class Node extends EventEmitter {
this.peers = peers;
this.txcount = txcount;
this.emit('stats');
this.events.emit('stats');
}
}
@@ -191,7 +193,7 @@ export default class Node extends EventEmitter {
this.blockTimes[height % BLOCK_TIME_HISTORY] = blockTime;
this.blockTime = blockTime;
this.emit('block');
this.events.emit('block');
}
}
-96
View File
@@ -1,96 +0,0 @@
import * as EventEmitter from 'events';
import Node from './node';
import Feed from './feed';
import { timestamp, Types, FeedMessage } from '@dotstats/common';
export default class Aggregator extends EventEmitter {
private nodes = new Set<Node>();
private feeds = new Set<Feed>();
private messages: Array<FeedMessage.Message> = [];
public height = 0 as Types.BlockNumber;
public blockTimestamp = 0 as Types.Timestamp;
constructor() {
super();
setInterval(() => this.timeoutCheck(), 10000);
}
public addNode(node: Node) {
this.nodes.add(node);
this.broadcast(Feed.addedNode(node));
node.once('disconnect', () => {
node.removeAllListeners();
this.nodes.delete(node);
this.broadcast(Feed.removedNode(node));
});
node.on('block', () => this.updateBlock(node));
node.on('stats', () => this.broadcast(Feed.stats(node)));
}
public addFeed(feed: Feed) {
this.feeds.add(feed);
const messages = [Feed.timeSync(), Feed.bestBlock(this.height, this.blockTimestamp)];
for (const node of this.nodes.values()) {
messages.push(Feed.addedNode(node));
}
feed.sendMessages(messages);
feed.once('disconnect', () => {
this.feeds.delete(feed);
});
}
public nodeList(): IterableIterator<Node> {
return this.nodes.values();
}
private broadcast(message: FeedMessage.Message) {
const queue = this.messages.length === 0;
this.messages.push(message);
if (queue) {
process.nextTick(() => {
const data = FeedMessage.serialize(this.messages);
this.messages = [];
for (const feed of this.feeds.values()) {
feed.sendData(data);
}
});
}
}
private timeoutCheck() {
const now = timestamp();
for (const node of this.nodes.values()) {
node.timeoutCheck(now);
}
this.broadcast(Feed.timeSync());
}
private updateBlock(node: Node) {
if (node.height > this.height) {
this.height = node.height;
this.blockTimestamp = node.blockTimestamp;
this.broadcast(Feed.bestBlock(this.height, this.blockTimestamp));
console.log(`New block ${this.height}`);
}
this.broadcast(Feed.imported(node));
console.log(`${node.name} imported ${node.height}, block time: ${node.blockTime / 1000}s, average: ${node.average / 1000}s | latency ${node.latency}`);
}
}
-80
View File
@@ -1,80 +0,0 @@
import * as WebSocket from 'ws';
import * as EventEmitter from 'events';
import Node from './node';
import { timestamp, Opaque, FeedMessage, Types, idGenerator } from '@dotstats/common';
const nextId = idGenerator<Types.FeedId>();
const { Actions } = FeedMessage;
export default class Feed extends EventEmitter {
public id: Types.FeedId;
private socket: WebSocket;
constructor(socket: WebSocket) {
super();
this.id = nextId();
this.socket = socket;
socket.on('error', () => this.disconnect());
socket.on('close', () => this.disconnect());
}
public static bestBlock(height: Types.BlockNumber, ts: Types.Timestamp): FeedMessage.Message {
return {
action: Actions.BestBlock,
payload: [height, ts]
};
}
public static addedNode(node: Node): FeedMessage.Message {
return {
action: Actions.AddedNode,
payload: [node.id, node.nodeDetails(), node.nodeStats(), node.blockDetails()]
};
}
public static removedNode(node: Node): FeedMessage.Message {
return {
action: Actions.RemovedNode,
payload: node.id
};
}
public static imported(node: Node): FeedMessage.Message {
return {
action: Actions.ImportedBlock,
payload: [node.id, node.blockDetails()]
};
}
public static stats(node: Node): FeedMessage.Message {
return {
action: Actions.NodeStats,
payload: [node.id, node.nodeStats()]
};
}
public static timeSync(): FeedMessage.Message {
return {
action: Actions.TimeSync,
payload: timestamp()
};
}
public sendData(data: FeedMessage.Data) {
this.socket.send(data);
}
public sendMessages(messages: Array<FeedMessage.Message>) {
this.socket.send(FeedMessage.serialize(messages))
}
private disconnect() {
this.socket.removeAllListeners();
this.socket.close();
this.emit('disconnect');
}
}
+7 -5
View File
@@ -1,9 +1,9 @@
import * as WebSocket from 'ws';
import Node from './node';
import Feed from './feed';
import Aggregator from './aggregator';
import Node from './Node';
import Feed from './Feed';
import Aggregator from './Aggregator';
const aggregator = new Aggregator;
const aggregator = new Aggregator();
// WebSocket for Nodes feeding telemetry data to the server
const incomingTelemetry = new WebSocket.Server({ port: 1024 });
@@ -16,7 +16,9 @@ console.log('Feed server listening on port 8080');
incomingTelemetry.on('connection', async (socket: WebSocket) => {
try {
aggregator.addNode(await Node.fromSocket(socket));
const node = await Node.fromSocket(socket);
aggregator.addNode(node);
} catch (err) {
console.error(err);
}
+1 -1
View File
@@ -42,7 +42,7 @@ export interface BestBlock {
interface SystemConnected {
msg: 'system.connected',
name: Types.NodeName,
chain: string,
chain: Types.ChainLabel,
config: string,
implementation: Types.NodeImplementation,
version: Types.NodeVersion,
+30 -2
View File
@@ -1,5 +1,5 @@
import { Opaque } from './helpers';
import { NodeId, NodeDetails, NodeStats, BlockNumber, BlockDetails, Timestamp } from './types';
import { NodeId, NodeDetails, NodeStats, BlockNumber, BlockDetails, Timestamp, ChainLabel } from './types';
export const Actions = {
BestBlock: 0 as 0,
@@ -8,6 +8,10 @@ export const Actions = {
ImportedBlock: 3 as 3,
NodeStats: 4 as 4,
TimeSync: 5 as 5,
AddedChain: 6 as 6,
RemovedChain: 7 as 7,
SubscribedTo: 8 as 8,
UnsubscribedFrom: 9 as 9
};
export type Action = typeof Actions[keyof typeof Actions];
@@ -47,6 +51,26 @@ export namespace Variants {
action: typeof Actions.TimeSync;
payload: Timestamp;
}
export interface AddedChainMessage extends MessageBase {
action: typeof Actions.AddedChain;
payload: ChainLabel;
}
export interface RemovedChainMessage extends MessageBase {
action: typeof Actions.RemovedChain;
payload: ChainLabel;
}
export interface SubscribedToMessage extends MessageBase {
action: typeof Actions.SubscribedTo;
payload: ChainLabel;
}
export interface UnsubscribedFromMessage extends MessageBase {
action: typeof Actions.UnsubscribedFrom;
payload: ChainLabel;
}
}
export type Message =
@@ -55,7 +79,11 @@ export type Message =
| Variants.RemovedNodeMessage
| Variants.ImportedBlockMessage
| Variants.NodeStatsMessage
| Variants.TimeSyncMessage;
| Variants.TimeSyncMessage
| Variants.AddedChainMessage
| Variants.RemovedChainMessage
| Variants.SubscribedToMessage
| Variants.UnsubscribedFromMessage;
/**
* Opaque data type to be sent to the feed. Passing through
+1
View File
@@ -1,6 +1,7 @@
import { Opaque } from './helpers';
import { Id } from './id';
export type ChainLabel = Opaque<string, 'ChainLabel'>;
export type FeedId = Id<'Feed'>;
export type NodeId = Id<'Node'>;
export type NodeName = Opaque<string, 'NodeName'>;
+2 -1
View File
@@ -5,7 +5,8 @@
.App-header {
width: 100%;
background: #fff;
background: #eee;
color: #000;
}
.App-logo {
+14 -13
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Types } from '@dotstats/common';
import { Node, Icon, Tile, Ago } from './components';
import { Chains, Node, Icon, Tile, Ago } from './components';
import { Connection } from './message';
import { State } from './state';
import { formatNumber } from './utils';
@@ -20,22 +20,33 @@ export default class App extends React.Component<{}, State> {
best: 0 as Types.BlockNumber,
blockTimestamp: 0 as Types.Timestamp,
timeDiff: 0 as Types.Milliseconds,
subscribed: null,
chains: new Set(),
nodes: new Map()
};
private connection: Promise<Connection>;
constructor(props: {}) {
super(props);
this.connect();
this.connection = Connection.create((changes) => {
if (changes) {
this.setState(changes);
}
return this.state;
});
}
public render() {
const { best, blockTimestamp, timeDiff } = this.state;
const { best, blockTimestamp, timeDiff, chains, subscribed } = this.state;
Ago.timeDiff = timeDiff;
return (
<div className="App">
<Chains chains={chains} subscribed={subscribed} connection={this.connection} />
<div className="App-header">
<Tile icon={blockIcon} title="Best Block">#{formatNumber(best)}</Tile>
<Tile icon={lastTimeIcon} title="Last Block"><Ago when={blockTimestamp} /></Tile>
@@ -63,16 +74,6 @@ export default class App extends React.Component<{}, State> {
);
}
private async connect() {
Connection.create((changes) => {
if (changes) {
this.setState(changes);
}
return this.state;
});
}
private nodes(): Node.Props[] {
return Array.from(this.state.nodes.values()).sort((a, b) => b.blockDetails[0] - a.blockDetails[0]);
}
@@ -0,0 +1,30 @@
.Chains {
background: #3c3c3b;
color: #fff;
padding: 0.25em 1em;
}
.Chains .Icon {
margin-right: 1em;
}
.Chains-chain {
padding: 0.25em 0.75em;
margin-left: 0.5em;
background: #222;
color: #999;
border-radius: 0.3em;
display: inline-block;
cursor: pointer;
font-family: monospace, sans-serif;
font-size: 0.8em;
font-weight: bold;
}
.Chains-chain-selected {
background: #eee;
color: #000;
border-radius: 0.3em 0.3em 0 0;
padding-bottom: 1em;
margin-bottom: -1em;
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Connection } from '../message';
import { Icon } from './Icon';
import { Types, Maybe } from '@dotstats/common';
import chainIcon from '../icons/link.svg';
import './Chains.css';
export namespace Chains {
export interface Props {
chains: Set<Types.ChainLabel>,
subscribed: Maybe<Types.ChainLabel>,
connection: Promise<Connection>
}
}
export class Chains extends React.Component<Chains.Props, {}> {
public render() {
return (
<div className="Chains">
<Icon src={chainIcon} alt="Observed chain" />
{
this.chains.map((chain) => this.renderChain(chain))
}
</div>
);
}
private renderChain(chain: Types.ChainLabel): React.ReactNode {
const className = chain === this.props.subscribed
? 'Chains-chain Chains-chain-selected'
: 'Chains-chain';
return (
<a key={chain} className={className} onClick={this.subscribe.bind(this, chain)}>
{chain}
</a>
)
}
private get chains(): Types.ChainLabel[] {
return Array.from(this.props.chains);
}
private async subscribe(chain: Types.ChainLabel) {
const connection = await this.props.connection;
connection.subscribe(chain);
}
}
@@ -1,3 +1,4 @@
export * from './Chains';
export * from './Icon';
export * from './Node';
export * from './Tile';
+54 -1
View File
@@ -63,6 +63,10 @@ export class Connection {
this.bindSocket();
}
public subscribe(chain: Types.ChainLabel) {
this.socket.send(`subscribe:${chain}`);
}
private bindSocket() {
this.state = this.update({ nodes: new Map() });
this.socket.addEventListener('message', this.handleMessages);
@@ -79,7 +83,8 @@ export class Connection {
private handleMessages = (event: MessageEvent) => {
const data = event.data as FeedMessage.Data;
const nodes = this.state.nodes;
const changes = { nodes };
const chains = this.state.chains;
const changes = { nodes, chains };
messages: for (const message of FeedMessage.deserialize(data)) {
switch (message.action) {
@@ -140,6 +145,44 @@ export class Connection {
continue messages;
}
case Actions.AddedChain: {
chains.add(message.payload);
this.autoSubscribe();
break;
}
case Actions.RemovedChain: {
chains.delete(message.payload);
if (this.state.subscribed === message.payload) {
nodes.clear();
this.state = this.update({ subscribed: null, nodes, chains });
this.autoSubscribe();
continue messages;
}
break;
}
case Actions.SubscribedTo: {
this.state = this.update({ subscribed: message.payload });
continue messages;
}
case Actions.UnsubscribedFrom: {
if (this.state.subscribed === message.payload) {
nodes.clear();
this.state = this.update({ subscribed: null, nodes });
}
continue messages;
}
default: {
continue messages;
}
@@ -149,6 +192,16 @@ export class Connection {
this.state = this.update(changes);
}
private autoSubscribe() {
const { subscribed, chains } = this.state;
if (subscribed == null && chains.size) {
const first = chains.values().next().value;
this.subscribe(first);
}
}
private handleDisconnect = async () => {
this.clean();
this.socket.close();
+4 -2
View File
@@ -1,11 +1,13 @@
import { Types } from '@dotstats/common';
import { Node } from './components/Node';
import { Types, Maybe } from '@dotstats/common';
export interface State {
best: Types.BlockNumber,
blockTimestamp: Types.Timestamp,
timeDiff: Types.Milliseconds,
nodes: Map<Types.NodeId, Node.Props>
subscribed: Maybe<Types.ChainLabel>,
chains: Set<Types.ChainLabel>,
nodes: Map<Types.NodeId, Node.Props>,
}
export type Update = <K extends keyof State>(changes: Pick<State, K> | null) => Readonly<State>;