diff --git a/packages/backend/src/aggregator.ts b/packages/backend/src/aggregator.ts index d743211..3eb8138 100644 --- a/packages/backend/src/aggregator.ts +++ b/packages/backend/src/aggregator.ts @@ -1,7 +1,7 @@ import * as EventEmitter from 'events'; import Node from './node'; import Feed from './feed'; -import { Types, IdSet, FeedMessage } from '@dotstats/common'; +import { timestamp, Types, IdSet, FeedMessage } from '@dotstats/common'; export default class Aggregator extends EventEmitter { private nodes = new IdSet(); @@ -9,6 +9,7 @@ export default class Aggregator extends EventEmitter { private messages: Array = []; public height = 0 as Types.BlockNumber; + public blockTimestamp = 0 as Types.Timestamp; constructor() { super(); @@ -34,7 +35,7 @@ export default class Aggregator extends EventEmitter { public addFeed(feed: Feed) { this.feeds.add(feed); - const messages = [Feed.bestBlock(this.height)]; + const messages = [Feed.timeSync(), Feed.bestBlock(this.height, this.blockTimestamp)]; for (const node of this.nodes.values()) { messages.push(Feed.addedNode(node)); @@ -69,18 +70,21 @@ export default class Aggregator extends EventEmitter { } private timeoutCheck() { - const now = Date.now(); + 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.broadcast(Feed.bestBlock(this.height, this.blockTimestamp)); console.log(`New block ${this.height}`); } diff --git a/packages/backend/src/feed.ts b/packages/backend/src/feed.ts index 18a7fd2..a56bc37 100644 --- a/packages/backend/src/feed.ts +++ b/packages/backend/src/feed.ts @@ -1,7 +1,7 @@ import * as WebSocket from 'ws'; import * as EventEmitter from 'events'; import Node from './node'; -import { Opaque, FeedMessage, Types, idGenerator } from '@dotstats/common'; +import { timestamp, Opaque, FeedMessage, Types, idGenerator } from '@dotstats/common'; const nextId = idGenerator(); const { Actions } = FeedMessage; @@ -21,10 +21,10 @@ export default class Feed extends EventEmitter { socket.on('close', () => this.disconnect()); } - public static bestBlock(height: Types.BlockNumber): FeedMessage.Message { + public static bestBlock(height: Types.BlockNumber, ts: Types.Timestamp): FeedMessage.Message { return { action: Actions.BestBlock, - payload: height + payload: [height, ts] }; } @@ -56,6 +56,13 @@ export default class Feed extends EventEmitter { }; } + public static timeSync(): FeedMessage.Message { + return { + action: Actions.TimeSync, + payload: timestamp() + }; + } + public sendData(data: FeedMessage.Data) { this.socket.send(data); } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 5106540..294c481 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,23 +1,18 @@ import * as WebSocket from 'ws'; -import * as express from 'express'; -import { createServer } from 'http'; import Node from './node'; import Feed from './feed'; import Aggregator from './aggregator'; const aggregator = new Aggregator; -const app = express(); -const server = createServer(app); // WebSocket for Nodes feeding telemetry data to the server const incomingTelemetry = new WebSocket.Server({ port: 1024 }); // WebSocket for web clients listening to the telemetry data aggregate -const telemetryFeed = new WebSocket.Server({ server }); +const telemetryFeed = new WebSocket.Server({ port: 8080 }); -app.get('/', function (req, res) { - res.send('See live listing at telemetry.polkadot.io/'); -}); +console.log('Telemetry server listening on port 1024'); +console.log('Feed server listening on port 8080'); incomingTelemetry.on('connection', async (socket: WebSocket) => { try { @@ -31,5 +26,3 @@ telemetryFeed.on('connection', (socket: WebSocket) => { aggregator.addFeed(new Feed(socket)); }); -console.log('Starting server on port 8080'); -server.listen(8080); diff --git a/packages/backend/src/node.ts b/packages/backend/src/node.ts index aea9073..053a337 100644 --- a/packages/backend/src/node.ts +++ b/packages/backend/src/node.ts @@ -1,15 +1,15 @@ import * as WebSocket from 'ws'; import * as EventEmitter from 'events'; -import { Maybe, Types, idGenerator } from '@dotstats/common'; +import { timestamp, Maybe, Types, idGenerator } from '@dotstats/common'; import { parseMessage, getBestBlock, Message, BestBlock, SystemInterval } from './message'; const BLOCK_TIME_HISTORY = 10; -const TIMEOUT = 1000 * 60 * 1; // 1 minute +const TIMEOUT = (1000 * 60 * 1) as Types.Milliseconds; // 1 minute const nextId = idGenerator(); export default class Node extends EventEmitter { - public lastMessage: number; + public lastMessage: Types.Timestamp; public id: Types.NodeId; public name: Types.NodeName; public implementation: Types.NodeImplementation; @@ -19,6 +19,7 @@ export default class Node extends EventEmitter { public height = 0 as Types.BlockNumber; public latency = 0 as Types.Milliseconds; public blockTime = 0 as Types.Milliseconds; + public blockTimestamp = 0 as Types.Timestamp; private peers = 0 as Types.PeerCount; private txcount = 0 as Types.TransactionCount; @@ -36,7 +37,7 @@ export default class Node extends EventEmitter { ) { super(); - this.lastMessage = Date.now(); + this.lastMessage = timestamp(); this.id = nextId(); this.socket = socket; this.name = name; @@ -53,7 +54,7 @@ export default class Node extends EventEmitter { return; } - this.lastMessage = Date.now(); + this.lastMessage = timestamp(); this.updateLatency(message.ts); const update = getBestBlock(message); @@ -111,7 +112,7 @@ export default class Node extends EventEmitter { }); } - public timeoutCheck(now: number) { + public timeoutCheck(now: Types.Timestamp) { if (this.lastMessage + TIMEOUT < now) { this.disconnect(); } @@ -126,7 +127,7 @@ export default class Node extends EventEmitter { } public blockDetails(): Types.BlockDetails { - return [this.height, this.best, this.blockTime]; + return [this.height, this.best, this.blockTime, this.blockTimestamp]; } public get average(): number { @@ -147,6 +148,14 @@ export default class Node extends EventEmitter { return sum / accounted; } + public get localBlockAt(): Types.Milliseconds { + if (!this.lastBlockAt) { + return 0 as Types.Milliseconds; + } + + return +(this.lastBlockAt || 0) as Types.Milliseconds; + } + private disconnect() { this.socket.removeAllListeners(); this.socket.close(); @@ -177,6 +186,7 @@ export default class Node extends EventEmitter { this.best = best; this.height = height; + this.blockTimestamp = timestamp(); this.lastBlockAt = time; this.blockTimes[height % BLOCK_TIME_HISTORY] = blockTime; this.blockTime = blockTime; diff --git a/packages/common/src/feed.ts b/packages/common/src/feed.ts index 9cadcc5..31570fa 100644 --- a/packages/common/src/feed.ts +++ b/packages/common/src/feed.ts @@ -1,5 +1,5 @@ import { Opaque } from './helpers'; -import { NodeId, NodeDetails, NodeStats, BlockNumber, BlockDetails } from './types'; +import { NodeId, NodeDetails, NodeStats, BlockNumber, BlockDetails, Timestamp } from './types'; export const Actions = { BestBlock: 0 as 0, @@ -7,6 +7,7 @@ export const Actions = { RemovedNode: 2 as 2, ImportedBlock: 3 as 3, NodeStats: 4 as 4, + TimeSync: 5 as 5, }; export type Action = typeof Actions[keyof typeof Actions]; @@ -19,7 +20,7 @@ export namespace Variants { export interface BestBlockMessage extends MessageBase { action: typeof Actions.BestBlock; - payload: BlockNumber; + payload: [BlockNumber, Timestamp]; } export interface AddedNodeMessage extends MessageBase { @@ -40,7 +41,12 @@ export namespace Variants { export interface NodeStatsMessage extends MessageBase { action: typeof Actions.NodeStats; payload: [NodeId, NodeStats]; - }; + } + + export interface TimeSyncMessage extends MessageBase { + action: typeof Actions.TimeSync; + payload: Timestamp; + } } export type Message = @@ -48,7 +54,8 @@ export type Message = | Variants.AddedNodeMessage | Variants.RemovedNodeMessage | Variants.ImportedBlockMessage - | Variants.NodeStatsMessage; + | Variants.NodeStatsMessage + | Variants.TimeSyncMessage; /** * Opaque data type to be sent to the feed. Passing through diff --git a/packages/common/src/helpers.ts b/packages/common/src/helpers.ts index 8eeea7c..ff46191 100644 --- a/packages/common/src/helpers.ts +++ b/packages/common/src/helpers.ts @@ -1,10 +1,10 @@ -import { Milliseconds } from './types'; +import { Milliseconds, Timestamp } from './types'; /** * PhantomData akin to Rust, because sometimes you need to be smarter than * the compiler. */ -export abstract class PhantomData

{ private __PHANTOM__: P } +export abstract class PhantomData

{ public __PHANTOM__: P } /** * Opaque type, similar to `opaque type` in Flow, or new types in Rust/C. @@ -31,3 +31,5 @@ export function sleep(time: Milliseconds): Promise { setTimeout(() => resolve(), time); }); } + +export const timestamp = Date.now as () => Timestamp; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index d5e49c6..855b25a 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -9,9 +9,10 @@ export type NodeVersion = Opaque; export type BlockNumber = Opaque; export type BlockHash = Opaque; export type Milliseconds = Opaque; +export type Timestamp = Opaque; export type PeerCount = Opaque; export type TransactionCount = Opaque; -export type BlockDetails = [BlockNumber, BlockHash, Milliseconds]; +export type BlockDetails = [BlockNumber, BlockHash, Milliseconds, Timestamp]; export type NodeDetails = [NodeName, NodeImplementation, NodeVersion]; export type NodeStats = [PeerCount, TransactionCount]; diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index 6420e78..9b6fbc0 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -1,5 +1,5 @@ .App { - text-align: center; + text-align: left; font-family: monospace, sans-serif; } diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 3c70d04..8f7704b 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Types } from '@dotstats/common'; -import { Node } from './Node'; -import { Icon } from './Icon'; +import { Node, Icon, Tile, Ago } from './components'; import { Connection } from './message'; import { State } from './state'; import { formatNumber } from './utils'; @@ -14,10 +13,13 @@ import transactionsIcon from './icons/inbox.svg'; import blockIcon from './icons/package.svg'; import blockHashIcon from './icons/file-binary.svg'; import blockTimeIcon from './icons/history.svg'; +import lastTimeIcon from './icons/dashboard.svg'; export default class App extends React.Component<{}, State> { public state: State = { best: 0 as Types.BlockNumber, + blockTimestamp: 0 as Types.Timestamp, + timeDiff: 0 as Types.Milliseconds, nodes: new Map() }; @@ -28,11 +30,14 @@ export default class App extends React.Component<{}, State> { } public render() { + const { best, blockTimestamp, timeDiff } = this.state; + + Ago.timeDiff = timeDiff; + return (

-
- #{formatNumber(this.state.best)} -
+ #{formatNumber(best)} + @@ -43,6 +48,7 @@ export default class App extends React.Component<{}, State> { + diff --git a/packages/frontend/src/components/Ago.tsx b/packages/frontend/src/components/Ago.tsx new file mode 100644 index 0000000..10b55a3 --- /dev/null +++ b/packages/frontend/src/components/Ago.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import './Tile.css'; +import { timestamp, Types } from '@dotstats/common'; + +export namespace Ago { + export interface Props { + when: Types.Timestamp, + } + + export interface State { + now: Types.Timestamp, + } +} + +const tickers = new Map void>(); + +function tick() { + const now = timestamp(); + + for (const ticker of tickers.values()) { + ticker(now); + } + + setTimeout(tick, 100); +} + +tick(); + +export namespace Ago { + export interface State { + now: Types.Timestamp + } +} + +export class Ago extends React.Component { + public static timeDiff = 0 as Types.Milliseconds; + + public state: Ago.State; + + constructor(props: Ago.Props) { + super(props); + + this.state = { + now: (timestamp() + Ago.timeDiff) as Types.Timestamp + }; + } + + public componentWillMount() { + tickers.set(this, (now) => { + this.setState({ + now: (now + Ago.timeDiff) as Types.Timestamp + }); + }) + } + + public componentWillUnmount() { + tickers.delete(this); + } + + public render() { + const ago = Math.max(this.state.now - this.props.when, 0) / 1000; + + let agoStr: string; + + if (ago < 10) { + agoStr = `${ago.toFixed(1)}s`; + } else if (ago < 60) { + agoStr = `${ago | 0}s`; + } else { + agoStr = `${ ago / 60 | 0}m`; + } + + return {agoStr} ago + } +} diff --git a/packages/frontend/src/Icon.css b/packages/frontend/src/components/Icon.css similarity index 100% rename from packages/frontend/src/Icon.css rename to packages/frontend/src/components/Icon.css diff --git a/packages/frontend/src/Icon.tsx b/packages/frontend/src/components/Icon.tsx similarity index 100% rename from packages/frontend/src/Icon.tsx rename to packages/frontend/src/components/Icon.tsx diff --git a/packages/frontend/src/Node.tsx b/packages/frontend/src/components/Node.tsx similarity index 73% rename from packages/frontend/src/Node.tsx rename to packages/frontend/src/components/Node.tsx index 99f510d..36388d7 100644 --- a/packages/frontend/src/Node.tsx +++ b/packages/frontend/src/components/Node.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { formatNumber, trimHash } from './utils'; +import { formatNumber, trimHash } from '../utils'; +import { Ago } from './Ago'; import { Types } from '@dotstats/common'; export namespace Node { @@ -13,7 +14,7 @@ export namespace Node { export function Node(props: Node.Props) { const [name, implementation, version] = props.nodeDetails; - const [height, hash, blockTime] = props.blockDetails; + const [height, hash, blockTime, blockTimestamp] = props.blockDetails; const [peers, txcount] = props.nodeStats; return ( @@ -24,7 +25,8 @@ export function Node(props: Node.Props) { - + + ); } diff --git a/packages/frontend/src/components/Tile.css b/packages/frontend/src/components/Tile.css new file mode 100644 index 0000000..aa0d66d --- /dev/null +++ b/packages/frontend/src/components/Tile.css @@ -0,0 +1,7 @@ +.Tile { + font-size: 2.5em; + padding: 20px; + text-align: left; + width: 7em; + display: inline-block; +} diff --git a/packages/frontend/src/components/Tile.tsx b/packages/frontend/src/components/Tile.tsx new file mode 100644 index 0000000..fa05e80 --- /dev/null +++ b/packages/frontend/src/components/Tile.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import './Tile.css'; +import { Icon } from './Icon'; + +export namespace Tile { + export interface Props { + title: string, + icon: string, + children?: React.ReactNode, + } +} + +export function Tile(props: Tile.Props) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts new file mode 100644 index 0000000..03bf546 --- /dev/null +++ b/packages/frontend/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './Icon'; +export * from './Node'; +export * from './Tile'; +export * from './Ago'; diff --git a/packages/frontend/src/message.ts b/packages/frontend/src/message.ts index f05a563..27192ab 100644 --- a/packages/frontend/src/message.ts +++ b/packages/frontend/src/message.ts @@ -1,4 +1,4 @@ -import { FeedMessage, Types, Maybe, sleep } from '@dotstats/common'; +import { timestamp, FeedMessage, Types, Maybe, sleep } from '@dotstats/common'; import { State, Update } from './state'; const { Actions } = FeedMessage; @@ -13,7 +13,6 @@ export class Connection { private static readonly address = `ws://${window.location.hostname}:8080`; - private static async socket(): Promise { let socket = await Connection.trySocket(); let timeout = TIMEOUT_BASE; @@ -61,11 +60,11 @@ export class Connection { constructor(socket: WebSocket, update: Update) { this.socket = socket; this.update = update; - this.state = update(null); this.bindSocket(); } private bindSocket() { + this.state = this.update({ nodes: new Map() }); this.socket.addEventListener('message', this.handleMessages); this.socket.addEventListener('close', this.handleDisconnect); this.socket.addEventListener('error', this.handleDisconnect); @@ -85,10 +84,13 @@ export class Connection { messages: for (const message of FeedMessage.deserialize(data)) { switch (message.action) { case Actions.BestBlock: { - this.state = this.update({ best: message.payload }); + const [best, blockTimestamp] = message.payload; + + this.state = this.update({ best, blockTimestamp }); continue messages; } + case Actions.AddedNode: { const [id, nodeDetails, nodeStats, blockDetails] = message.payload; const node = { id, nodeDetails, nodeStats, blockDetails }; @@ -97,14 +99,15 @@ export class Connection { break; } + case Actions.RemovedNode: { nodes.delete(message.payload); break; } + case Actions.ImportedBlock: { const [id, blockDetails] = message.payload; - const node = nodes.get(id); if (!node) { @@ -115,9 +118,9 @@ export class Connection { break; } + case Actions.NodeStats: { const [id, nodeStats] = message.payload; - const node = nodes.get(id); if (!node) { @@ -128,8 +131,17 @@ export class Connection { break; } + + case Actions.TimeSync: { + this.state = this.update({ + timeDiff: (timestamp() - message.payload) as Types.Milliseconds + }); + + continue messages; + } + default: { - return; + continue messages; } } } diff --git a/packages/frontend/src/state.ts b/packages/frontend/src/state.ts index b6cb86f..cb2545f 100644 --- a/packages/frontend/src/state.ts +++ b/packages/frontend/src/state.ts @@ -1,8 +1,10 @@ import { Types } from '@dotstats/common'; -import { Node } from './Node'; +import { Node } from './components/Node'; export interface State { best: Types.BlockNumber, + blockTimestamp: Types.Timestamp, + timeDiff: Types.Milliseconds, nodes: Map } diff --git a/packages/frontend/tslint.json b/packages/frontend/tslint.json index 02fdaa5..e73d4bb 100644 --- a/packages/frontend/tslint.json +++ b/packages/frontend/tslint.json @@ -8,6 +8,7 @@ }, "rules": { "ordered-imports": false, + "object-literal-sort-keys": false, "no-console": false, "no-unused-variable": [true, {"ignore-pattern": "^_"}], "no-empty": false,
{txcount} #{formatNumber(height)} {trimHash(hash, 16)}{blockTime / 1000}s{(blockTime / 1000).toFixed(3)}s