Turbo Render (#298)

* More responsive React updates on scroll

* `Icon`s now use shadow dom

* Faster Sparkline

* Recycle table rows

* Separate Header from Chain to avoid vdom diffing

* Separate THead from Row.HEADER to avoid vdom diffing

* Throttle rendering updates on chain tabs, also styles

* Minor tweaks and fixes

* Created components for all columns

* Wrapping up Column refactor

* Rename Row--td to Column

* Lazy `Ago`

* Update styles for faster layouting

* Minor cleanup

* Fix Connection

* Use shadow DOM in `PolkadotIcon`

* Comments and tweaks for the List component

* Faster Tooltip and Truncate

* Minor tweaks

* Tooltiped columns can now be copied

* Future-proof Connection

* Remove the <div> wrapper from Icon

* Fix dash on missing graph data

* Clean up some SVGs

* Cleanup and comments

* Localize the use of `previousKeys` to `recalculateKeys`

* Custom appState disjoint from React component state

* Make appState and appUpdate refs readonly

* Cleanup
This commit is contained in:
Maciej Hirsz
2020-11-11 13:41:01 +01:00
committed by GitHub
parent 675776c3e1
commit ebb01c1a7d
72 changed files with 1863 additions and 1118 deletions
+2 -5
View File
@@ -16,17 +16,15 @@
"clean": "rm -rf node_modules build .nyc env-config.js report*.json yarn-error.log" "clean": "rm -rf node_modules build .nyc env-config.js report*.json yarn-error.log"
}, },
"dependencies": { "dependencies": {
"@fnando/sparkline": "maciejhirsz/sparkline",
"@polkadot/util-crypto": "^2.8.1", "@polkadot/util-crypto": "^2.8.1",
"@types/react-measure": "^2.0.6", "@types/react-measure": "^2.0.6",
"blakejs": "^1.1.0", "blakejs": "^1.1.0",
"husky": "^4.2.5", "husky": "^4.2.5",
"lint-staged": "^10.1.7", "lint-staged": "^10.1.7",
"react": "^16.13.1", "react": "^17.0.1",
"react-dom": "^16.13.1", "react-dom": "^17.0.1",
"react-measure": "^2.3.0", "react-measure": "^2.3.0",
"react-scripts-ts": "3.1.0", "react-scripts-ts": "3.1.0",
"react-svg": "4.1.1",
"stable": "^0.1.8", "stable": "^0.1.8",
"tslint": "^6.1.1" "tslint": "^6.1.1"
}, },
@@ -34,7 +32,6 @@
"@types/node": "^13.13.2", "@types/node": "^13.13.2",
"@types/react": "^16.9.34", "@types/react": "^16.9.34",
"@types/react-dom": "^16.9.6", "@types/react-dom": "^16.9.6",
"@types/react-svg": "3.0.0",
"@types/tape": "^4.13.0", "@types/tape": "^4.13.0",
"babel-preset-env": "^1.7.0", "babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1", "babel-preset-react": "^6.24.1",
+18 -18
View File
@@ -1,17 +1,17 @@
import { Types } from './common'; import { Types } from './common';
import { State, UpdateBound } from './state'; import { State, Update } from './state';
import { ConsensusDetail } from './common/types'; import { ConsensusDetail } from './common/types';
// Number of blocks which are kept in memory // Number of blocks which are kept in memory
const BLOCKS_LIMIT = 50; const BLOCKS_LIMIT = 50;
export class AfgHandling { export class AfgHandling {
private updateState: UpdateBound; constructor(
private getState: () => Readonly<State>; private readonly appUpdate: Update,
private readonly appState: Readonly<State>
constructor(updateState: UpdateBound, getState: () => Readonly<State>) { ) {
this.updateState = updateState; this.appUpdate = appUpdate;
this.getState = getState; this.appState = appState;
} }
public receivedAuthoritySet( public receivedAuthoritySet(
@@ -19,19 +19,19 @@ export class AfgHandling {
authorities: Types.Authorities authorities: Types.Authorities
) { ) {
if ( if (
this.getState().authoritySetId != null && this.appState.authoritySetId != null &&
authoritySetId !== this.getState().authoritySetId authoritySetId !== this.appState.authoritySetId
) { ) {
// the visualization is restarted when we receive a new authority set // the visualization is restarted when we receive a new authority set
this.updateState({ this.appUpdate({
authoritySetId, authoritySetId,
authorities, authorities,
consensusInfo: [], consensusInfo: [],
displayConsensusLoadingScreen: false, displayConsensusLoadingScreen: false,
}); });
} else if (this.getState().authoritySetId == null) { } else if (this.appState.authoritySetId == null) {
// initial display // initial display
this.updateState({ this.appUpdate({
authoritySetId, authoritySetId,
authorities, authorities,
consensusInfo: [], consensusInfo: [],
@@ -46,7 +46,7 @@ export class AfgHandling {
finalizedNumber: Types.BlockNumber, finalizedNumber: Types.BlockNumber,
finalizedHash: Types.BlockHash finalizedHash: Types.BlockHash
) { ) {
const state = this.getState(); const state = this.appState;
if (finalizedNumber < state.best - BLOCKS_LIMIT) { if (finalizedNumber < state.best - BLOCKS_LIMIT) {
return; return;
} }
@@ -108,7 +108,7 @@ export class AfgHandling {
this.backfill(state.consensusInfo, finalizedNumber, op, addr, addr); this.backfill(state.consensusInfo, finalizedNumber, op, addr, addr);
this.pruneBlocks(state.consensusInfo); this.pruneBlocks(state.consensusInfo);
this.updateState({ consensusInfo: state.consensusInfo }); this.appUpdate({ consensusInfo: state.consensusInfo });
} }
public receivedPre( public receivedPre(
@@ -117,7 +117,7 @@ export class AfgHandling {
voter: Types.Address, voter: Types.Address,
what: string what: string
) { ) {
const state = this.getState(); const state = this.appState;
if (height < state.best - BLOCKS_LIMIT) { if (height < state.best - BLOCKS_LIMIT) {
return; return;
} }
@@ -165,11 +165,11 @@ export class AfgHandling {
consensusInfo[index][1][addr][voter].ImplicitPointer = height; consensusInfo[index][1][addr][voter].ImplicitPointer = height;
return true; return true;
}; };
const consensusInfo = this.getState().consensusInfo; const consensusInfo = this.appState.consensusInfo;
this.backfill(consensusInfo, height, op, addr, voter); this.backfill(consensusInfo, height, op, addr, voter);
this.pruneBlocks(consensusInfo); this.pruneBlocks(consensusInfo);
this.updateState({ consensusInfo }); this.appUpdate({ consensusInfo });
} }
// Initializes the `ConsensusView` with empty objects. // Initializes the `ConsensusView` with empty objects.
@@ -239,7 +239,7 @@ export class AfgHandling {
} }
let firstBlockNumber = consensusInfo[consensusInfo.length - 1][0]; let firstBlockNumber = consensusInfo[consensusInfo.length - 1][0];
const limit = this.getState().best - BLOCKS_LIMIT; const limit = this.appState.best - BLOCKS_LIMIT;
if (firstBlockNumber < limit) { if (firstBlockNumber < limit) {
firstBlockNumber = limit as Types.BlockNumber; firstBlockNumber = limit as Types.BlockNumber;
} }
+1
View File
@@ -2,6 +2,7 @@
text-align: left; text-align: left;
font-family: Roboto, Helvetica, Arial, sans-serif; font-family: Roboto, Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
min-width: 1318px;
} }
.App-no-telemetry { .App-no-telemetry {
+37 -27
View File
@@ -4,15 +4,24 @@ import { AllChains, Chains, Chain, Ago, OfflineIndicator } from './components';
import { Row, Column } from './components/List'; import { Row, Column } from './components/List';
import { Connection } from './Connection'; import { Connection } from './Connection';
import { Persistent, PersistentObject, PersistentSet } from './persist'; import { Persistent, PersistentObject, PersistentSet } from './persist';
import { State, Node, ChainData, comparePinnedChains } from './state'; import {
bindState,
State,
Update,
Node,
ChainData,
comparePinnedChains,
} from './state';
import { getHashData } from './utils'; import { getHashData } from './utils';
import stable from 'stable'; import stable from 'stable';
import './App.css'; import './App.css';
export default class App extends React.Component<{}, State> { export default class App extends React.Component<{}, {}> {
public state: State;
private chainsCache: ChainData[] = []; private chainsCache: ChainData[] = [];
// Custom state for finer control over updates
private readonly appState: Readonly<State>;
private readonly appUpdate: Update;
private readonly settings: PersistentObject<State.Settings>; private readonly settings: PersistentObject<State.Settings>;
private readonly pins: PersistentSet<Types.NodeName>; private readonly pins: PersistentSet<Types.NodeName>;
private readonly sortBy: Persistent<Maybe<number>>; private readonly sortBy: Persistent<Maybe<number>>;
@@ -52,28 +61,28 @@ export default class App extends React.Component<{}, State> {
const selectedColumns = this.selectedColumns(settings); const selectedColumns = this.selectedColumns(settings);
this.sortBy.set(null); this.sortBy.set(null);
this.setState({ settings, selectedColumns, sortBy: null }); this.appUpdate({ settings, selectedColumns, sortBy: null });
} }
); );
this.pins = new PersistentSet<Types.NodeName>('pinned_names', (pins) => { this.pins = new PersistentSet<Types.NodeName>('pinned_names', (pins) => {
const { nodes } = this.state; const { nodes } = this.appState;
nodes.mutEachAndSort((node) => node.setPinned(pins.has(node.name))); nodes.mutEachAndSort((node) => node.setPinned(pins.has(node.name)));
this.setState({ nodes, pins }); this.appUpdate({ nodes, pins });
}); });
this.sortBy = new Persistent<Maybe<number>>('sortBy', null, (sortBy) => { this.sortBy = new Persistent<Maybe<number>>('sortBy', null, (sortBy) => {
const compare = this.getComparator(sortBy); const compare = this.getComparator(sortBy);
this.state.nodes.setComparator(compare); this.appState.nodes.setComparator(compare);
this.setState({ sortBy }); this.appUpdate({ sortBy });
}); });
const { tab = '' } = getHashData(); const { tab = '' } = getHashData();
this.state = { this.appUpdate = bindState(this, {
status: 'offline', status: 'offline',
best: 0 as Types.BlockNumber, best: 0 as Types.BlockNumber,
finalized: 0 as Types.BlockNumber, finalized: 0 as Types.BlockNumber,
@@ -93,23 +102,23 @@ export default class App extends React.Component<{}, State> {
sortBy: this.sortBy.get(), sortBy: this.sortBy.get(),
selectedColumns: this.selectedColumns(this.settings.raw()), selectedColumns: this.selectedColumns(this.settings.raw()),
tab, tab,
};
this.state.nodes.setComparator(this.getComparator(this.sortBy.get()));
this.connection = Connection.create(this.pins, (changes) => {
if (changes) {
this.setState(changes);
}
return this.state;
}); });
this.appState = this.appUpdate({});
const comparator = this.getComparator(this.sortBy.get());
this.appState.nodes.setComparator(comparator);
this.connection = Connection.create(
this.pins,
this.appState,
this.appUpdate
);
setInterval(() => (this.chainsCache = []), 10000); // Wipe sorted chains cache every 10 seconds setInterval(() => (this.chainsCache = []), 10000); // Wipe sorted chains cache every 10 seconds
} }
public render() { public render() {
const { timeDiff, subscribed, status, tab } = this.state; const { timeDiff, subscribed, status, tab } = this.appState;
const chains = this.chains(); const chains = this.chains();
Ago.timeDiff = timeDiff; Ago.timeDiff = timeDiff;
@@ -141,7 +150,8 @@ export default class App extends React.Component<{}, State> {
connection={this.connection} connection={this.connection}
/> />
<Chain <Chain
appState={this.state} appState={this.appState}
appUpdate={this.appUpdate}
connection={this.connection} connection={this.connection}
settings={this.settings} settings={this.settings}
pins={this.pins} pins={this.pins}
@@ -152,7 +162,7 @@ export default class App extends React.Component<{}, State> {
); );
} }
public componentWillMount() { public componentDidMount() {
window.addEventListener('keydown', this.onKeyPress); window.addEventListener('keydown', this.onKeyPress);
window.addEventListener('hashchange', this.onHashChange); window.addEventListener('hashchange', this.onHashChange);
} }
@@ -170,8 +180,8 @@ export default class App extends React.Component<{}, State> {
event.preventDefault(); event.preventDefault();
const { subscribed } = this.state; const { subscribed } = this.appState;
const chains = Array.from(this.state.chains.keys()); const chains = Array.from(this.appState.chains.keys());
let index = 0; let index = 0;
@@ -196,12 +206,12 @@ export default class App extends React.Component<{}, State> {
}; };
private chains(): ChainData[] { private chains(): ChainData[] {
if (this.chainsCache.length === this.state.chains.size) { if (this.chainsCache.length === this.appState.chains.size) {
return this.chainsCache; return this.chainsCache;
} }
this.chainsCache = stable.inplace( this.chainsCache = stable.inplace(
Array.from(this.state.chains.values()), Array.from(this.appState.chains.values()),
(a, b) => { (a, b) => {
const pinned = comparePinnedChains(a.label, b.label); const pinned = comparePinnedChains(a.label, b.label);
@@ -223,7 +233,7 @@ export default class App extends React.Component<{}, State> {
} }
private getComparator(sortBy: Maybe<number>): Compare<Node> { private getComparator(sortBy: Maybe<number>): Compare<Node> {
const columns = this.state.selectedColumns; const columns = this.appState.selectedColumns;
if (sortBy != null) { if (sortBy != null) {
const [index, rev] = sortBy < 0 ? [~sortBy, -1] : [sortBy, 1]; const [index, rev] = sortBy < 0 ? [~sortBy, -1] : [sortBy, 1];
+78 -77
View File
@@ -1,18 +1,21 @@
import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from './common'; import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from './common';
import { import { State, Update, Node, ChainData, PINNED_CHAINS } from './state';
State,
Update,
Node,
UpdateBound,
ChainData,
PINNED_CHAINS,
} from './state';
import { PersistentSet } from './persist'; import { PersistentSet } from './persist';
import { getHashData, setHashData } from './utils'; import { getHashData, setHashData } from './utils';
import { AfgHandling } from './AfgHandling'; import { AfgHandling } from './AfgHandling';
import { VIS_AUTHORITIES_LIMIT } from './components/Consensus'; import { VIS_AUTHORITIES_LIMIT } from './components/Consensus';
import { Column } from './components/List';
import { ACTIONS } from './common/feed'; import { ACTIONS } from './common/feed';
import {
Column,
LocationColumn,
PeersColumn,
TxsColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
} from './components/List';
const TIMEOUT_BASE = (1000 * 5) as Types.Milliseconds; // 5 seconds const TIMEOUT_BASE = (1000 * 5) as Types.Milliseconds; // 5 seconds
const TIMEOUT_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes const TIMEOUT_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes
@@ -26,9 +29,10 @@ declare global {
export class Connection { export class Connection {
public static async create( public static async create(
pins: PersistentSet<Types.NodeName>, pins: PersistentSet<Types.NodeName>,
update: Update appState: Readonly<State>,
appUpdate: Update
): Promise<Connection> { ): Promise<Connection> {
return new Connection(await Connection.socket(), update, pins); return new Connection(await Connection.socket(), appState, appUpdate, pins);
} }
private static readonly utf8decoder = new TextDecoder('utf-8'); private static readonly utf8decoder = new TextDecoder('utf-8');
@@ -102,27 +106,22 @@ export class Connection {
private resubscribeTo: Maybe<Types.ChainLabel> = getHashData().chain; private resubscribeTo: Maybe<Types.ChainLabel> = getHashData().chain;
// flag whether or not FE should subscribe to consensus updates on reconnect // flag whether or not FE should subscribe to consensus updates on reconnect
private resubscribeSendFinality: boolean = getHashData().tab === 'consensus'; private resubscribeSendFinality: boolean = getHashData().tab === 'consensus';
// flag used to throttle DOM updates to window frame rate
private isUpdating = false;
private socket: WebSocket;
private state: Readonly<State>;
private readonly update: Update;
private readonly pins: PersistentSet<Types.NodeName>;
constructor( constructor(
socket: WebSocket, private socket: WebSocket,
update: Update, private readonly appState: Readonly<State>,
pins: PersistentSet<Types.NodeName> private readonly appUpdate: Update,
private readonly pins: PersistentSet<Types.NodeName>
) { ) {
this.socket = socket;
this.update = update;
this.pins = pins;
this.bindSocket(); this.bindSocket();
} }
public subscribe(chain: Types.ChainLabel) { public subscribe(chain: Types.ChainLabel) {
if (this.state.subscribed != null && this.state.subscribed !== chain) { if (
this.state = this.update({ this.appState.subscribed != null &&
this.appState.subscribed !== chain
) {
this.appUpdate({
tab: 'list', tab: 'list',
}); });
setHashData({ chain, tab: 'list' }); setHashData({ chain, tab: 'list' });
@@ -134,7 +133,7 @@ export class Connection {
} }
public subscribeConsensus(chain: Types.ChainLabel) { public subscribeConsensus(chain: Types.ChainLabel) {
if (this.state.authorities.length <= VIS_AUTHORITIES_LIMIT) { if (this.appState.authorities.length <= VIS_AUTHORITIES_LIMIT) {
setHashData({ chain }); setHashData({ chain });
this.resubscribeSendFinality = true; this.resubscribeSendFinality = true;
this.socket.send(`send-finality:${chain}`); this.socket.send(`send-finality:${chain}`);
@@ -142,7 +141,7 @@ export class Connection {
} }
public resetConsensus() { public resetConsensus() {
this.state = this.update({ this.appUpdate({
consensusInfo: new Array() as Types.ConsensusInfo, consensusInfo: new Array() as Types.ConsensusInfo,
displayConsensusLoadingScreen: true, displayConsensusLoadingScreen: true,
authorities: [] as Types.Address[], authorities: [] as Types.Address[],
@@ -156,14 +155,10 @@ export class Connection {
} }
public handleMessages = (messages: FeedMessage.Message[]) => { public handleMessages = (messages: FeedMessage.Message[]) => {
const { nodes, chains, sortBy, selectedColumns } = this.state; const { nodes, chains, sortBy, selectedColumns } = this.appState;
const nodesStateRef = nodes.ref; const nodesStateRef = nodes.ref;
const updateState: UpdateBound = (state) => { const afg = new AfgHandling(this.appUpdate, this.appState);
this.state = this.update(state);
};
const getState = () => this.state;
const afg = new AfgHandling(updateState, getState);
let sortByColumn: Maybe<Column> = null; let sortByColumn: Maybe<Column> = null;
@@ -176,13 +171,7 @@ export class Connection {
switch (message.action) { switch (message.action) {
case ACTIONS.FeedVersion: { case ACTIONS.FeedVersion: {
if (message.payload !== VERSION) { if (message.payload !== VERSION) {
this.state = this.update({ status: 'upgrade-requested' }); return this.newVersion();
this.clean();
// Force reload from the server
setTimeout(() => window.location.reload(true), 3000);
return;
} }
break; break;
@@ -193,7 +182,7 @@ export class Connection {
nodes.mutEach((node) => node.newBestBlock()); nodes.mutEach((node) => node.newBestBlock());
this.state = this.update({ best, blockTimestamp, blockAverage }); this.appUpdate({ best, blockTimestamp, blockAverage });
break; break;
} }
@@ -201,7 +190,7 @@ export class Connection {
case ACTIONS.BestFinalized: { case ACTIONS.BestFinalized: {
const [finalized /*, hash */] = message.payload; const [finalized /*, hash */] = message.payload;
this.state = this.update({ finalized }); this.appUpdate({ finalized });
break; break;
} }
@@ -257,7 +246,7 @@ export class Connection {
nodes.mutAndMaybeSort( nodes.mutAndMaybeSort(
id, id,
(node) => node.updateLocation([lat, lon, city]), (node) => node.updateLocation([lat, lon, city]),
sortByColumn === Column.LOCATION sortByColumn === LocationColumn
); );
break; break;
@@ -277,8 +266,8 @@ export class Connection {
nodes.mutAndMaybeSort( nodes.mutAndMaybeSort(
id, id,
(node) => node.updateFinalized(height, hash), (node) => node.updateFinalized(height, hash),
sortByColumn === Column.FINALIZED || sortByColumn === FinalizedBlockColumn ||
sortByColumn === Column.FINALIZED_HASH sortByColumn === FinalizedHashColumn
); );
break; break;
@@ -290,7 +279,7 @@ export class Connection {
nodes.mutAndMaybeSort( nodes.mutAndMaybeSort(
id, id,
(node) => node.updateStats(nodeStats), (node) => node.updateStats(nodeStats),
sortByColumn === Column.PEERS || sortByColumn === Column.TXS sortByColumn === PeersColumn || sortByColumn === TxsColumn
); );
break; break;
@@ -302,7 +291,7 @@ export class Connection {
nodes.mutAndMaybeSort( nodes.mutAndMaybeSort(
id, id,
(node) => node.updateHardware(nodeHardware), (node) => node.updateHardware(nodeHardware),
sortByColumn === Column.UPLOAD || sortByColumn === Column.DOWNLOAD sortByColumn === UploadColumn || sortByColumn === DownloadColumn
); );
break; break;
@@ -314,14 +303,14 @@ export class Connection {
nodes.mutAndMaybeSort( nodes.mutAndMaybeSort(
id, id,
(node) => node.updateIO(nodeIO), (node) => node.updateIO(nodeIO),
sortByColumn === Column.STATE_CACHE sortByColumn === StateCacheColumn
); );
break; break;
} }
case ACTIONS.TimeSync: { case ACTIONS.TimeSync: {
this.state = this.update({ this.appUpdate({
timeDiff: (timestamp() - message.payload) as Types.Milliseconds, timeDiff: (timestamp() - message.payload) as Types.Milliseconds,
}); });
@@ -338,7 +327,7 @@ export class Connection {
chains.set(label, { label, nodeCount }); chains.set(label, { label, nodeCount });
} }
this.state = this.update({ chains }); this.appUpdate({ chains });
break; break;
} }
@@ -346,9 +335,9 @@ export class Connection {
case ACTIONS.RemovedChain: { case ACTIONS.RemovedChain: {
chains.delete(message.payload); chains.delete(message.payload);
if (this.state.subscribed === message.payload) { if (this.appState.subscribed === message.payload) {
nodes.clear(); nodes.clear();
this.state = this.update({ subscribed: null, nodes, chains }); this.appUpdate({ subscribed: null, nodes, chains });
this.resetConsensus(); this.resetConsensus();
} }
@@ -358,16 +347,16 @@ export class Connection {
case ACTIONS.SubscribedTo: { case ACTIONS.SubscribedTo: {
nodes.clear(); nodes.clear();
this.state = this.update({ subscribed: message.payload, nodes }); this.appUpdate({ subscribed: message.payload, nodes });
break; break;
} }
case ACTIONS.UnsubscribedFrom: { case ACTIONS.UnsubscribedFrom: {
if (this.state.subscribed === message.payload) { if (this.appState.subscribed === message.payload) {
nodes.clear(); nodes.clear();
this.state = this.update({ subscribed: null, nodes }); this.appUpdate({ subscribed: null, nodes });
} }
break; break;
@@ -416,12 +405,8 @@ export class Connection {
} }
} }
if (nodes.hasChangedSince(nodesStateRef) && !this.isUpdating) { if (nodes.hasChangedSince(nodesStateRef)) {
this.isUpdating = true; this.appUpdate({ nodes });
window.requestAnimationFrame(() => {
this.update({ nodes });
this.isUpdating = false;
});
} }
this.autoSubscribe(); this.autoSubscribe();
@@ -430,19 +415,19 @@ export class Connection {
private bindSocket() { private bindSocket() {
this.ping(); this.ping();
if (this.state) { if (this.appState) {
const { nodes } = this.state; const { nodes } = this.appState;
nodes.clear(); nodes.clear();
} }
this.state = this.update({ this.appUpdate({
status: 'online', status: 'online',
}); });
if (this.state.subscribed) { if (this.appState.subscribed) {
this.resubscribeTo = this.state.subscribed; this.resubscribeTo = this.appState.subscribed;
this.resubscribeSendFinality = this.state.sendFinality; this.resubscribeSendFinality = this.appState.sendFinality;
this.state = this.update({ subscribed: null, sendFinality: false }); this.appUpdate({ subscribed: null, sendFinality: false });
} }
this.socket.addEventListener('message', this.handleFeedData); this.socket.addEventListener('message', this.handleFeedData);
@@ -479,8 +464,14 @@ export class Connection {
const latency = timestamp() - this.pingSent; const latency = timestamp() - this.pingSent;
this.pingSent = null; this.pingSent = null;
}
console.log('latency', latency); private newVersion() {
this.appUpdate({ status: 'upgrade-requested' });
this.clean();
// Force reload from the server
setTimeout(() => window.location.reload(true), 3000);
} }
private clean() { private clean() {
@@ -493,18 +484,28 @@ export class Connection {
} }
private handleFeedData = (event: MessageEvent) => { private handleFeedData = (event: MessageEvent) => {
const data = let data: FeedMessage.Data;
typeof event.data === 'string'
? ((event.data as any) as FeedMessage.Data) if (typeof event.data === 'string') {
: ((Connection.utf8decoder.decode( data = (event.data as any) as FeedMessage.Data;
event.data } else {
) as any) as FeedMessage.Data); const u8aData = new Uint8Array(event.data);
// Future-proofing for when we switch to binary feed
if (u8aData[0] === 0x00) {
return this.newVersion();
}
const str = Connection.utf8decoder.decode(event.data);
data = (str as any) as FeedMessage.Data;
}
this.handleMessages(FeedMessage.deserialize(data)); this.handleMessages(FeedMessage.deserialize(data));
}; };
private autoSubscribe() { private autoSubscribe() {
const { subscribed, chains } = this.state; const { subscribed, chains } = this.appState;
const { resubscribeTo, resubscribeSendFinality } = this; const { resubscribeTo, resubscribeSendFinality } = this;
if (subscribed) { if (subscribed) {
@@ -540,7 +541,7 @@ export class Connection {
} }
private handleDisconnect = async () => { private handleDisconnect = async () => {
this.state = this.update({ status: 'offline' }); this.appUpdate({ status: 'offline' });
this.resetConsensus(); this.resetConsensus();
this.clean(); this.clean();
this.socket.close(); this.socket.close();
+6
View File
@@ -181,7 +181,13 @@ export class SortedCollection<Item extends { id: number }> {
return; return;
} }
const index = sortedIndexOf(item, this.list, this.compare);
mutator(item); mutator(item);
if (index >= this.focus.start && index < this.focus.end) {
this.changeRef += 1;
}
} }
public mutAndSort(id: number, mutator: (item: Item) => void) { public mutAndSort(id: number, mutator: (item: Item) => void) {
+23 -5
View File
@@ -38,15 +38,29 @@ export class Ago extends React.Component<Ago.Props, Ago.State> {
public state: Ago.State; public state: Ago.State;
private agoStr: string;
constructor(props: Ago.Props) { constructor(props: Ago.Props) {
super(props); super(props);
this.state = { this.state = {
now: (timestamp() - Ago.timeDiff) as Types.Timestamp, now: (timestamp() - Ago.timeDiff) as Types.Timestamp,
}; };
this.agoStr = this.stringify(props.when, this.state.now);
} }
public componentWillMount() { public shouldComponentUpdate(nextProps: Ago.Props, nextState: Ago.State) {
const nextAgoStr = this.stringify(nextProps.when, nextState.now);
if (this.agoStr !== nextAgoStr) {
this.agoStr = nextAgoStr;
return true;
}
return false;
}
public componentDidMount() {
tickers.set(this, (now) => { tickers.set(this, (now) => {
this.setState({ this.setState({
now: (now - Ago.timeDiff) as Types.Timestamp, now: (now - Ago.timeDiff) as Types.Timestamp,
@@ -63,7 +77,13 @@ export class Ago extends React.Component<Ago.Props, Ago.State> {
return <span>-</span>; return <span>-</span>;
} }
const ago = Math.max(this.state.now - this.props.when, 0) / 1000; return (
<span title={new Date(this.props.when).toUTCString()}>{this.agoStr}</span>
);
}
private stringify(when: number, now: number): string {
const ago = Math.max(now - when, 0) / 1000;
let agoStr: string; let agoStr: string;
@@ -83,8 +103,6 @@ export class Ago extends React.Component<Ago.Props, Ago.State> {
agoStr += ' ago'; agoStr += ' ago';
} }
return ( return agoStr;
<span title={new Date(this.props.when).toUTCString()}>{agoStr}</span>
);
} }
} }
+2 -2
View File
@@ -19,12 +19,12 @@ export class AllChains extends React.Component<AllChains.Props, {}> {
const close = subscribed ? `#list/${subscribed}` : '#list'; const close = subscribed ? `#list/${subscribed}` : '#list';
return ( return (
<React.Fragment> <>
<a className="AllChains-overlay" href={close} /> <a className="AllChains-overlay" href={close} />
<div className="AllChains"> <div className="AllChains">
{chains.map((chain) => this.renderChain(chain))} {chains.map((chain) => this.renderChain(chain))}
</div> </div>
</React.Fragment> </>
); );
} }
-18
View File
@@ -1,21 +1,3 @@
.Chain-header {
width: 100%;
height: 108px;
overflow: hidden;
background: #fff;
color: #000;
min-width: 1350px;
position: relative;
}
.Chain-tabs {
position: absolute;
right: 5px;
bottom: 10px;
width: 200px;
text-align: right;
}
.Chain-content-container { .Chain-content-container {
position: absolute; position: absolute;
left: 0; left: 0;
+19 -64
View File
@@ -1,21 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { Connection } from '../../Connection'; import { Connection } from '../../Connection';
import { Types, Maybe } from '../../common'; import { Types, Maybe } from '../../common';
import { State as AppState } from '../../state'; import { State as AppState, Update as AppUpdate } from '../../state';
import { formatNumber, secondsWithPrecision, getHashData } from '../../utils'; import { getHashData } from '../../utils';
import { Tab } from './'; import { Header } from './';
import { Tile, Ago, List, Map, Settings, Consensus } from '../'; import { Tile, Ago, List, Map, Settings, Consensus } from '../';
import { Persistent, PersistentObject, PersistentSet } from '../../persist'; import { Persistent, PersistentObject, PersistentSet } from '../../persist';
import blockIcon from '../../icons/cube.svg';
import finalizedIcon from '../../icons/cube-alt.svg';
import blockTimeIcon from '../../icons/history.svg';
import lastTimeIcon from '../../icons/watch.svg';
import listIcon from '../../icons/list-alt-regular.svg';
import worldIcon from '../../icons/location.svg';
import settingsIcon from '../../icons/settings.svg';
import consensusIcon from '../../icons/cube-alt.svg';
import './Chain.css'; import './Chain.css';
export namespace Chain { export namespace Chain {
@@ -23,6 +14,7 @@ export namespace Chain {
export interface Props { export interface Props {
appState: Readonly<AppState>; appState: Readonly<AppState>;
appUpdate: AppUpdate;
connection: Promise<Connection>; connection: Promise<Connection>;
settings: PersistentObject<AppState.Settings>; settings: PersistentObject<AppState.Settings>;
pins: PersistentSet<Types.NodeName>; pins: PersistentSet<Types.NodeName>;
@@ -64,56 +56,14 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
return ( return (
<div className="Chain"> <div className="Chain">
<div className="Chain-header"> <Header
<Tile icon={blockIcon} title="Best Block"> best={best}
#{formatNumber(best)} finalized={finalized}
</Tile> blockAverage={blockAverage}
<Tile icon={finalizedIcon} title="Finalized Block"> blockTimestamp={blockTimestamp}
#{formatNumber(finalized)} currentTab={currentTab}
</Tile> setDisplay={this.setDisplay}
<Tile icon={blockTimeIcon} title="Average Time"> />
{blockAverage == null
? '-'
: secondsWithPrecision(blockAverage / 1000)}
</Tile>
<Tile icon={lastTimeIcon} title="Last Block">
<Ago when={blockTimestamp} />
</Tile>
<div className="Chain-tabs">
<Tab
icon={listIcon}
label="List"
display="list"
tab=""
current={currentTab}
setDisplay={this.setDisplay}
/>
<Tab
icon={worldIcon}
label="Map"
display="map"
tab="map"
current={currentTab}
setDisplay={this.setDisplay}
/>
<Tab
icon={consensusIcon}
label="Consensus"
display="consensus"
tab="consensus"
current={currentTab}
setDisplay={this.setDisplay}
/>
<Tab
icon={settingsIcon}
label="Settings"
display="settings"
tab="settings"
current={currentTab}
setDisplay={this.setDisplay}
/>
</div>
</div>
<div className="Chain-content-container"> <div className="Chain-content-container">
<div className="Chain-content">{this.renderContent()}</div> <div className="Chain-content">{this.renderContent()}</div>
</div> </div>
@@ -128,14 +78,19 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
return <Settings settings={this.props.settings} />; return <Settings settings={this.props.settings} />;
} }
const { appState, connection, pins, sortBy } = this.props; const { appState, appUpdate, connection, pins, sortBy } = this.props;
if (display === 'consensus') { if (display === 'consensus') {
return <Consensus appState={appState} connection={connection} />; return <Consensus appState={appState} connection={connection} />;
} }
return display === 'list' ? ( return display === 'list' ? (
<List appState={appState} pins={pins} sortBy={sortBy} /> <List
appState={appState}
appUpdate={appUpdate}
pins={pins}
sortBy={sortBy}
/>
) : ( ) : (
<Map appState={appState} /> <Map appState={appState} />
); );
+17
View File
@@ -0,0 +1,17 @@
.Header {
width: 100%;
height: 108px;
overflow: hidden;
background: #fff;
color: #000;
min-width: 1350px;
position: relative;
}
.Header-tabs {
position: absolute;
right: 5px;
bottom: 10px;
width: 200px;
text-align: right;
}
+97
View File
@@ -0,0 +1,97 @@
import * as React from 'react';
import { Types, Maybe } from '../../common';
import { formatNumber, secondsWithPrecision } from '../../utils';
import { Tab, Chain } from './';
import { Tile, Ago } from '../';
import blockIcon from '../../icons/cube.svg';
import finalizedIcon from '../../icons/cube-alt.svg';
import blockTimeIcon from '../../icons/history.svg';
import lastTimeIcon from '../../icons/watch.svg';
import listIcon from '../../icons/list-alt-regular.svg';
import worldIcon from '../../icons/location.svg';
import settingsIcon from '../../icons/settings.svg';
import consensusIcon from '../../icons/cube-alt.svg';
import './Header.css';
export namespace Header {
export interface Props {
best: Types.BlockNumber;
finalized: Types.BlockNumber;
blockTimestamp: Types.Timestamp;
blockAverage: Maybe<Types.Milliseconds>;
currentTab: Chain.Display;
setDisplay: (display: Chain.Display) => void;
}
}
export class Header extends React.Component<Header.Props, {}> {
public shouldComponentUpdate(nextProps: Header.Props) {
return (
this.props.best !== nextProps.best ||
this.props.finalized !== nextProps.finalized ||
this.props.blockTimestamp !== nextProps.blockTimestamp ||
this.props.blockAverage !== nextProps.blockAverage ||
this.props.currentTab !== nextProps.currentTab
);
}
public render() {
const { best, finalized, blockTimestamp, blockAverage } = this.props;
const { currentTab, setDisplay } = this.props;
return (
<div className="Header">
<Tile icon={blockIcon} title="Best Block">
#{formatNumber(best)}
</Tile>
<Tile icon={finalizedIcon} title="Finalized Block">
#{formatNumber(finalized)}
</Tile>
<Tile icon={blockTimeIcon} title="Average Time">
{blockAverage == null
? '-'
: secondsWithPrecision(blockAverage / 1000)}
</Tile>
<Tile icon={lastTimeIcon} title="Last Block">
<Ago when={blockTimestamp} />
</Tile>
<div className="Header-tabs">
<Tab
icon={listIcon}
label="List"
display="list"
tab=""
current={currentTab}
setDisplay={setDisplay}
/>
<Tab
icon={worldIcon}
label="Map"
display="map"
tab="map"
current={currentTab}
setDisplay={setDisplay}
/>
<Tab
icon={consensusIcon}
label="Consensus"
display="consensus"
tab="consensus"
current={currentTab}
setDisplay={setDisplay}
/>
<Tab
icon={settingsIcon}
label="Settings"
display="settings"
tab="settings"
current={currentTab}
setDisplay={setDisplay}
/>
</div>
</div>
);
}
}
+6 -7
View File
@@ -1,24 +1,23 @@
.Chain-Tab { .Chain-Tab {
display: inline-block; display: inline-block;
}
.Chain-Tab .Icon {
margin-right: 5px; margin-right: 5px;
font-size: 24px; font-size: 24px;
line-height: 24px;
height: 24px;
width: 24px;
padding: 6px; padding: 6px;
color: #555; color: #555;
cursor: pointer; cursor: pointer;
padding: 10px; padding: 10px;
border-radius: 40px; border-radius: 40px;
transition: background-color 0.15s linear;
} }
.Chain-Tab:hover .Icon { .Chain-Tab:hover {
background: #ccc; background: #ccc;
} }
.Chain-Tab-on .Icon, .Chain-Tab-on,
.Chain-Tab-on:hover .Icon { .Chain-Tab-on:hover {
background: #e6007a; background: #e6007a;
color: #fff; color: #fff;
} }
+2 -2
View File
@@ -23,8 +23,8 @@ export class Tab extends React.Component<Tab.Props, {}> {
const className = highlight ? 'Chain-Tab-on Chain-Tab' : 'Chain-Tab'; const className = highlight ? 'Chain-Tab-on Chain-Tab' : 'Chain-Tab';
return ( return (
<div className={className} onClick={this.onClick}> <div className={className} onClick={this.onClick} title={label}>
<Icon src={icon} alt={label} /> <Icon src={icon} />
</div> </div>
); );
} }
+1
View File
@@ -1,2 +1,3 @@
export * from './Chain'; export * from './Chain';
export * from './Tab'; export * from './Tab';
export * from './Header';
+21 -23
View File
@@ -1,28 +1,25 @@
.Chains { .Chains {
background: #b5aeae; background: #e6007a;
color: #000; color: #000;
padding: 0 76px 0 16px; padding: 0 76px 0 16px;
height: 40px; height: 40px;
min-width: 1318px; /* min-width is 1350 - 76 - 16 to account for padding */
min-width: 1258px;
position: relative; position: relative;
} }
.Chains-chain { .Chains-chain {
top: 4px;
padding: 0 12px; padding: 0 12px;
background: #b5aeae; color: #fff;
color: #444;
display: inline-block; display: inline-block;
border-right: 1px solid rgba(255, 255, 255, 0.5); margin-right: 4px;
height: 40px; height: 36;
line-height: 40px; line-height: 36px;
cursor: pointer; cursor: pointer;
font-size: 0.8em; font-size: 0.8em;
font-weight: bold;
position: relative; position: relative;
} border-radius: 4px 4px 0 0;
.Chains-chain:first-child {
border-left: 1px solid rgba(255, 255, 255, 0.5);
} }
.Chains-all-chains { .Chains-all-chains {
@@ -49,27 +46,28 @@
margin: 0; margin: 0;
height: 28px; height: 28px;
width: 28px; width: 28px;
color: #3c3c3b; color: #fff;
} }
.Chains-node-count { .Chains-node-count {
padding: 0 5px;
display: inline-block; display: inline-block;
padding: 0 0.5em 0.1em; height: 20px;
border-radius: 1em; border-radius: 20px;
background: #8c8787; background: #fff;
color: #fff; color: #e6007a;
font-weight: normal;
text-shadow: rgba(0, 0, 0, 0.5) 0 1px 0;
font-size: 0.9em; font-size: 0.9em;
line-height: 1.4em; line-height: 20px;
margin: 0 -0.3em 0 0.3em; margin: 0 -0.5em 0 0.5em;
} }
.Chains-chain-selected { .Chains-chain-selected {
background: #fff; background: #fff;
color: #000; color: #393838;
font-weight: bold;
} }
.Chains-chain-selected .Chains-node-count { .Chains-chain-selected .Chains-node-count {
background: #e6007a; background: #393838;
color: #fff;
} }
+42 -10
View File
@@ -5,7 +5,7 @@ import { Types, Maybe } from '../common';
import { ChainData } from '../state'; import { ChainData } from '../state';
import githubIcon from '../icons/mark-github.svg'; import githubIcon from '../icons/mark-github.svg';
import listIcon from '../icons/three-bars.svg'; import listIcon from '../icons/kebab-horizontal.svg';
import './Chains.css'; import './Chains.css';
export namespace Chains { export namespace Chains {
@@ -16,8 +16,29 @@ export namespace Chains {
} }
} }
// How many chains should be rendered in the DOM
const VISIBLE_CAP = 16;
// Milliseconds, sets the minimum time between the renders
const RENDER_THROTTLE = 1000;
export class Chains extends React.Component<Chains.Props, {}> { export class Chains extends React.Component<Chains.Props, {}> {
private lastRender = performance.now();
private clicked: Maybe<Types.ChainLabel>;
public shouldComponentUpdate(nextProps: Chains.Props) {
if (nextProps.subscribed !== this.clicked) {
this.clicked = nextProps.subscribed;
}
return (
this.props.subscribed !== nextProps.subscribed ||
performance.now() - this.lastRender > RENDER_THROTTLE
);
}
public render() { public render() {
this.lastRender = performance.now();
const allChainsHref = this.props.subscribed const allChainsHref = this.props.subscribed
? `#all-chains/${this.props.subscribed}` ? `#all-chains/${this.props.subscribed}`
: `#all-chains`; : `#all-chains`;
@@ -25,16 +46,21 @@ export class Chains extends React.Component<Chains.Props, {}> {
return ( return (
<div className="Chains"> <div className="Chains">
{chains.map((chain) => this.renderChain(chain))} {chains.slice(0, VISIBLE_CAP).map((chain) => this.renderChain(chain))}
<a className="Chains-all-chains" href={allChainsHref}> <a
<Icon src={listIcon} alt="All Chains" /> className="Chains-all-chains"
href={allChainsHref}
title="All Chains"
>
<Icon src={listIcon} />
</a> </a>
<a <a
className="Chains-fork-me" className="Chains-fork-me"
href="https://github.com/paritytech/substrate-telemetry" href="https://github.com/paritytech/substrate-telemetry"
target="_blank" target="_blank"
title="Fork Me!"
> >
<Icon src={githubIcon} alt="Fork Me!" /> <Icon src={githubIcon} />
</a> </a>
</div> </div>
); );
@@ -43,10 +69,11 @@ export class Chains extends React.Component<Chains.Props, {}> {
private renderChain(chain: ChainData): React.ReactNode { private renderChain(chain: ChainData): React.ReactNode {
const { label, nodeCount } = chain; const { label, nodeCount } = chain;
const className = let className = 'Chains-chain';
label === this.props.subscribed
? 'Chains-chain Chains-chain-selected' if (label === this.props.subscribed) {
: 'Chains-chain'; className += ' Chains-chain-selected';
}
return ( return (
<a <a
@@ -54,7 +81,7 @@ export class Chains extends React.Component<Chains.Props, {}> {
className={className} className={className}
onClick={this.subscribe.bind(this, label)} onClick={this.subscribe.bind(this, label)}
> >
{label}{' '} {label}
<span className="Chains-node-count" title="Node Count"> <span className="Chains-node-count" title="Node Count">
{nodeCount} {nodeCount}
</span> </span>
@@ -63,6 +90,11 @@ export class Chains extends React.Component<Chains.Props, {}> {
} }
private async subscribe(chain: Types.ChainLabel) { private async subscribe(chain: Types.ChainLabel) {
if (chain === this.clicked) {
return;
}
this.clicked = chain;
const connection = await this.props.connection; const connection = await this.props.connection;
connection.subscribe(chain); connection.subscribe(chain);
@@ -195,9 +195,9 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
]; ];
finalizedInfo = matrice.ImplicitFinalized ? ( finalizedInfo = matrice.ImplicitFinalized ? (
<Icon className="implicit" src={finalizedIcon} alt="" /> <Icon className="implicit" src={finalizedIcon} />
) : ( ) : (
<Icon className="explicit" src={finalizedIcon} alt="" /> <Icon className="explicit" src={finalizedIcon} />
); );
finalizedHash = matrice.FinalizedHash ? ( finalizedHash = matrice.FinalizedHash ? (
@@ -301,25 +301,17 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
let statPrecommit; let statPrecommit;
if (implicitPrevote) { if (implicitPrevote) {
statPrevote = ( statPrevote = <Icon src={checkIcon} className="implicit" />;
<Icon src={checkIcon} className="implicit" alt="Implicit Prevote" />
);
} }
if (implicitPrecommit) { if (implicitPrecommit) {
statPrecommit = ( statPrecommit = <Icon src={checkIcon} className="implicit" />;
<Icon src={checkIcon} className="implicit" alt="Implicit Precommit" />
);
} }
if (prevote) { if (prevote) {
statPrevote = ( statPrevote = <Icon src={checkIcon} className="explicit" />;
<Icon src={checkIcon} className="explicit" alt="Prevote" />
);
} }
if (precommit) { if (precommit) {
statPrecommit = ( statPrecommit = <Icon src={checkIcon} className="explicit" />;
<Icon src={checkIcon} className="explicit" alt="Precommit" />
);
} }
return ( return (
@@ -329,7 +321,7 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
</span> </span>
); );
} else { } else {
return <Icon src={hatchingIcon} className="hatching" alt="" />; return <Icon src={hatchingIcon} className="hatching" />;
} }
} }
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export class Filter extends React.Component<Filter.Props, {}> {
private filterInput: HTMLInputElement; private filterInput: HTMLInputElement;
public componentWillMount() { public componentDidMount() {
window.addEventListener('keyup', this.onWindowKeyUp); window.addEventListener('keyup', this.onWindowKeyUp);
} }
+2 -2
View File
@@ -8,7 +8,7 @@
display: inline-block; display: inline-block;
} }
.Icon svg { .Icon svg, .Icon-symbol-root symbol {
width: auto; width: 1em;
height: 1em; height: 1em;
} }
+57 -18
View File
@@ -1,36 +1,75 @@
import * as React from 'react'; import * as React from 'react';
import ReactSVG from 'react-svg';
import './Icon.css'; import './Icon.css';
import { getSVGShadowRoot, W3SVG } from '../utils';
export interface Props { export namespace Icon {
src: string; export interface Props {
alt?: string; src: string;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
}
} }
export class Icon extends React.Component<{}, Props> { const symbols = new Map<string, string>();
public props: Props;
public shouldComponentUpdate(nextProps: Props) { let symbolId = 0;
// Lazily render the icon in the DOM, so that we can referenced
// it by id using shadow DOM.
function renderShadowIcon(src: string): string {
let symbol = symbols.get(src);
if (!symbol) {
symbol = `icon${symbolId}`;
symbolId += 1;
symbols.set(src, symbol);
fetch(src).then(async (response) => {
const html = await response.text();
const temp = document.createElement('div');
temp.innerHTML = html;
const tempSVG = temp.querySelector('svg') as SVGSVGElement;
const symEl = document.createElementNS(W3SVG, 'symbol');
const viewBox = tempSVG.getAttribute('viewBox');
symEl.setAttribute('id', symbol as string);
if (viewBox) {
symEl.setAttribute('viewBox', viewBox);
}
for (const child of Array.from(tempSVG.childNodes)) {
symEl.appendChild(child);
}
getSVGShadowRoot().appendChild(symEl);
});
}
return symbol;
}
export class Icon extends React.Component<Icon.Props, {}> {
public props: Icon.Props;
public shouldComponentUpdate(nextProps: Icon.Props) {
return ( return (
this.props.src !== nextProps.src || this.props.src !== nextProps.src ||
this.props.alt !== nextProps.alt ||
this.props.className !== nextProps.className this.props.className !== nextProps.className
); );
} }
public render() { public render() {
const { alt, className, onClick, src } = this.props; const { className, onClick, src } = this.props;
const symbol = renderShadowIcon(src);
// Use `href` for a shadow DOM reference to the rendered icon
return ( return (
<ReactSVG <svg className={`Icon ${className || ''}`} onClick={onClick}>
key={this.props.src} <use href={`#${symbol}`} />
title={alt} </svg>
className={`Icon ${className || ''}`}
path={src}
onClick={onClick}
/>
); );
} }
} }
-410
View File
@@ -1,410 +0,0 @@
import * as React from 'react';
import { Types, Maybe, timestamp } from '../../common';
import { State, Node } from '../../state';
import { Truncate } from './';
import { Ago, Icon, Tooltip, Sparkline, PolkadotIcon } from '../';
import {
formatNumber,
getHashData,
milliOrSecond,
secondsWithPrecision,
} from '../../utils';
export interface Column {
label: string;
icon: string;
width?: number;
setting?: keyof State.Settings;
sortBy?: (node: Node) => any;
render: (node: Node) => React.ReactElement<any> | string;
}
import nodeIcon from '../../icons/server.svg';
import nodeLocationIcon from '../../icons/location.svg';
import nodeValidatorIcon from '../../icons/shield.svg';
import nodeTypeIcon from '../../icons/terminal.svg';
import networkIdIcon from '../../icons/fingerprint.svg';
import peersIcon from '../../icons/broadcast.svg';
import transactionsIcon from '../../icons/inbox.svg';
import blockIcon from '../../icons/cube.svg';
import finalizedIcon from '../../icons/cube-alt.svg';
import blockHashIcon from '../../icons/file-binary.svg';
import blockTimeIcon from '../../icons/history.svg';
import propagationTimeIcon from '../../icons/dashboard.svg';
import lastTimeIcon from '../../icons/watch.svg';
import uploadIcon from '../../icons/cloud-upload.svg';
import downloadIcon from '../../icons/cloud-download.svg';
import stateIcon from '../../icons/git-branch.svg';
import networkIcon from '../../icons/network.svg';
import uptimeIcon from '../../icons/pulse.svg';
import externalLinkIcon from '../../icons/link-external.svg';
import parityPolkadotIcon from '../../icons/dot.svg';
import paritySubstrateIcon from '../../icons/substrate.svg';
import polkadotJsIcon from '../../icons/polkadot-js.svg';
import airalabRobonomicsIcon from '../../icons/robonomics.svg';
import chainXIcon from '../../icons/chainx.svg';
import edgewareIcon from '../../icons/edgeware.svg';
import joystreamIcon from '../../icons/joystream.svg';
import ladderIcon from '../../icons/laddernetwork.svg';
import cennznetIcon from '../../icons/cennznet.svg';
import crabIcon from '../../icons/crab.svg';
import darwiniaIcon from '../../icons/darwinia.svg';
import turingIcon from '../../icons/turingnetwork.svg';
import dothereumIcon from '../../icons/dothereum.svg';
import katalchainIcon from '../../icons/katalchain.svg';
import bifrostIcon from '../../icons/bifrost.svg';
import totemIcon from '../../icons/totem.svg';
import nodleIcon from '../../icons/nodle.svg';
import zeroIcon from '../../icons/zero.svg';
import unknownImplementationIcon from '../../icons/question-solid.svg';
const ICONS = {
'parity-polkadot': parityPolkadotIcon,
'Parity Polkadot': parityPolkadotIcon,
'polkadot-js': polkadotJsIcon,
'airalab-robonomics': airalabRobonomicsIcon,
'substrate-node': paritySubstrateIcon,
'Substrate Node': paritySubstrateIcon,
'edgeware-node': edgewareIcon,
'Edgeware Node': edgewareIcon,
'joystream-node': joystreamIcon,
ChainX: chainXIcon,
'ladder-node': ladderIcon,
'cennznet-node': cennznetIcon,
'Darwinia Crab': crabIcon,
Darwinia: darwiniaIcon,
'turing-node': turingIcon,
dothereum: dothereumIcon,
katalchain: katalchainIcon,
'bifrost-node': bifrostIcon,
'totem-meccano-node': totemIcon,
Totem: totemIcon,
'Nodle Chain Node': nodleIcon,
subzero: zeroIcon,
};
export namespace Column {
export const NAME: Column = {
label: 'Node',
icon: nodeIcon,
sortBy: ({ sortableName }) => sortableName,
render: ({ name }) => <Truncate text={name} position="left" />,
};
export const VALIDATOR: Column = {
label: 'Validator',
icon: nodeValidatorIcon,
width: 16,
setting: 'validator',
sortBy: ({ validator }) => validator || '',
render: ({ validator }) => {
return validator ? (
<Tooltip text={validator} copy={true}>
<span className="Row-validator">
<PolkadotIcon account={validator} size={16} />
</span>
</Tooltip>
) : (
'-'
);
},
};
export const LOCATION: Column = {
label: 'Location',
icon: nodeLocationIcon,
width: 140,
setting: 'location',
sortBy: ({ city }) => city || '',
render: ({ city }) =>
city ? <Truncate position="left" text={city} /> : '-',
};
export const IMPLEMENTATION: Column = {
label: 'Implementation',
icon: nodeTypeIcon,
width: 90,
setting: 'implementation',
sortBy: ({ sortableVersion }) => sortableVersion,
render: ({ implementation, version }) => {
const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?'];
const implIcon = ICONS[implementation] || unknownImplementationIcon;
return (
<Tooltip text={`${implementation} v${version}`}>
<Icon src={implIcon} /> {semver}
</Tooltip>
);
},
};
export const NETWORK_ID: Column = {
label: 'Network ID',
icon: networkIdIcon,
width: 90,
setting: 'networkId',
sortBy: ({ networkId }) => networkId || '',
render: ({ networkId }) =>
networkId ? <Truncate position="left" text={networkId} /> : '-',
};
export const PEERS: Column = {
label: 'Peer Count',
icon: peersIcon,
width: 26,
setting: 'peers',
sortBy: ({ peers }) => peers,
render: ({ peers }) => `${peers}`,
};
export const TXS: Column = {
label: 'Transactions in Queue',
icon: transactionsIcon,
width: 26,
setting: 'txs',
sortBy: ({ txs }) => txs,
render: ({ txs }) => `${txs}`,
};
export const UPLOAD: Column = {
label: 'Upload Bandwidth',
icon: uploadIcon,
width: 40,
setting: 'upload',
sortBy: ({ upload }) => (upload.length < 3 ? 0 : upload[upload.length - 1]),
render: ({ upload, chartstamps }) => {
if (upload.length < 3) {
return '-';
}
return (
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBandwidth}
values={upload}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
);
},
};
export const DOWNLOAD: Column = {
label: 'Download Bandwidth',
icon: downloadIcon,
width: 40,
setting: 'download',
sortBy: ({ download }) =>
download.length < 3 ? 0 : download[download.length - 1],
render: ({ download, chartstamps }) => {
if (download.length < 3) {
return '-';
}
return (
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBandwidth}
values={download}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
);
},
};
export const STATE_CACHE: Column = {
label: 'State Cache Size',
icon: stateIcon,
width: 40,
setting: 'stateCacheSize',
sortBy: ({ stateCacheSize }) =>
stateCacheSize.length < 3 ? 0 : stateCacheSize[stateCacheSize.length - 1],
render: ({ stateCacheSize, chartstamps }) => {
if (stateCacheSize.length < 3) {
return '-';
}
return (
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBytes}
values={stateCacheSize}
stamps={chartstamps}
minScale={MEMORY_SCALE}
/>
);
},
};
export const BLOCK_NUMBER: Column = {
label: 'Block',
icon: blockIcon,
width: 88,
setting: 'blocknumber',
sortBy: ({ height }) => height || 0,
render: ({ height }) => `#${formatNumber(height)}`,
};
export const BLOCK_HASH: Column = {
label: 'Block Hash',
icon: blockHashIcon,
width: 154,
setting: 'blockhash',
sortBy: ({ hash }) => hash || '',
render: ({ hash }) => <Truncate position="right" text={hash} copy={true} />,
};
export const FINALIZED: Column = {
label: 'Finalized Block',
icon: finalizedIcon,
width: 88,
setting: 'finalized',
sortBy: ({ finalized }) => finalized || 0,
render: ({ finalized }) => `#${formatNumber(finalized)}`,
};
export const FINALIZED_HASH: Column = {
label: 'Finalized Block Hash',
icon: blockHashIcon,
width: 154,
setting: 'finalizedhash',
sortBy: ({ finalizedHash }) => finalizedHash || '',
render: ({ finalizedHash }) => (
<Truncate position="right" text={finalizedHash} copy={true} />
),
};
export const BLOCK_TIME: Column = {
label: 'Block Time',
icon: blockTimeIcon,
width: 80,
setting: 'blocktime',
sortBy: ({ blockTime }) => (blockTime == null ? Infinity : blockTime),
render: ({ blockTime }) => `${secondsWithPrecision(blockTime / 1000)}`,
};
export const BLOCK_PROPAGATION: Column = {
label: 'Block Propagation Time',
icon: propagationTimeIcon,
width: 58,
setting: 'blockpropagation',
sortBy: ({ propagationTime }) =>
propagationTime == null ? Infinity : propagationTime,
render: ({ propagationTime }) =>
propagationTime == null ? '∞' : milliOrSecond(propagationTime),
};
export const BLOCK_LAST_TIME: Column = {
label: 'Last Block Time',
icon: lastTimeIcon,
width: 100,
setting: 'blocklasttime',
sortBy: ({ blockTimestamp }) => blockTimestamp || 0,
render: ({ blockTimestamp }) => <Ago when={blockTimestamp} />,
};
export const UPTIME: Column = {
label: 'Node Uptime',
icon: uptimeIcon,
width: 58,
setting: 'uptime',
sortBy: ({ connectedAt }) => connectedAt || 0,
render: ({ connectedAt }) => <Ago when={connectedAt} justTime={true} />,
};
export const NETWORK_STATE: Column = {
label: 'NetworkState',
icon: networkIcon,
width: 16,
setting: 'networkstate',
render: ({ id }) => {
const chainLabel = getHashData().chain;
if (!chainLabel) {
return '-';
}
const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`;
return (
<a href={uri} target="_blank">
<Icon src={externalLinkIcon} />
</a>
);
},
};
}
const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
const BANDWIDTH_SCALE = 1024 * 1024;
const MEMORY_SCALE = 2 * 1024 * 1024;
const URI_BASE =
window.location.protocol === 'https:'
? `/network_state/`
: `http://${window.location.hostname}:8000/network_state/`;
function formatStamp(stamp: Types.Timestamp): string {
const passed = ((timestamp() - stamp) / 1000) | 0;
const hours = (passed / 3600) | 0;
const minutes = ((passed % 3600) / 60) | 0;
const seconds = passed % 60 | 0;
return hours
? `${hours}h ago`
: minutes
? `${minutes}m ago`
: `${seconds}s ago`;
}
function formatMemory(kbs: number, stamp: Maybe<Types.Timestamp>): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
const mbs = (kbs / 1024) | 0;
if (mbs >= 1000) {
return `${(mbs / 1024).toFixed(1)} GB${ago}`;
} else {
return `${mbs} MB${ago}`;
}
}
function formatBytes(bytes: number, stamp: Maybe<Types.Timestamp>): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB${ago}`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB${ago}`;
} else if (bytes >= 1000) {
return `${(bytes / 1024).toFixed(1)} kB${ago}`;
} else {
return `${bytes} B${ago}`;
}
}
function formatBandwidth(bps: number, stamp: Maybe<Types.Timestamp>): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bps >= 1024 * 1024) {
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`;
} else if (bps >= 1000) {
return `${(bps / 1024).toFixed(1)} kB/s${ago}`;
} else {
return `${bps | 0} B/s${ago}`;
}
}
function formatCPU(cpu: number, stamp: Maybe<Types.Timestamp>): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
const fractionDigits = cpu > 100 ? 0 : cpu > 10 ? 1 : cpu > 1 ? 2 : 3;
return `${cpu.toFixed(fractionDigits)}%${ago}`;
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/file-binary.svg';
export class BlockHashColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Block Hash';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'blockhash';
public static readonly sortBy = ({ hash }: Node) => hash || '';
private data: Maybe<string>;
private copy: Maybe<Tooltip.CopyCallback>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.hash;
}
render() {
const { hash } = this.props.node;
this.data = hash;
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={hash} position="right" copy={this.onCopy} />
<Truncate text={hash} chars={16} />
</td>
);
}
private onCopy = (copy: Tooltip.CopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}
@@ -0,0 +1,27 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { formatNumber } from '../../../utils';
import icon from '../../../icons/cube.svg';
export class BlockNumberColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Block';
public static readonly icon = icon;
public static readonly width = 88;
public static readonly setting = 'blocknumber';
public static readonly sortBy = ({ height }: Node) => height;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.height;
}
render() {
const { height } = this.props.node;
this.data = height;
return <td className="Column">{`#${formatNumber(height)}`}</td>;
}
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { milliOrSecond } from '../../../utils';
import icon from '../../../icons/dashboard.svg';
export class BlockPropagationColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Block Propagation Time';
public static readonly icon = icon;
public static readonly width = 58;
public static readonly setting = 'blockpropagation';
public static readonly sortBy = ({ propagationTime }: Node) =>
propagationTime == null ? Infinity : propagationTime;
private data: Maybe<number>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.propagationTime;
}
render() {
const { propagationTime } = this.props.node;
const print =
propagationTime == null ? '∞' : milliOrSecond(propagationTime);
this.data = propagationTime;
return <td className="Column">{print}</td>;
}
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { secondsWithPrecision } from '../../../utils';
import icon from '../../../icons/history.svg';
export class BlockTimeColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Block Time';
public static readonly icon = icon;
public static readonly width = 80;
public static readonly setting = 'blocktime';
public static readonly sortBy = ({ blockTime }: Node) =>
blockTime == null ? Infinity : blockTime;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.blockTime;
}
render() {
const { blockTime } = this.props.node;
this.data = blockTime;
return (
<td className="Column">{`${secondsWithPrecision(blockTime / 1000)}`}</td>
);
}
}
@@ -0,0 +1,43 @@
.Column {
text-align: left;
padding: 6px 13px;
height: 19px;
position: relative;
white-space: nowrap;
}
.Column-truncate {
position: absolute;
left: 0;
right: 0;
top: 0;
padding: 6px 13px;
overflow: hidden;
text-overflow: ellipsis;
}
.Column-Tooltip {
position: initial !important;
padding: inherit !important;
}
.Column-validator {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
}
.Column-validator:hover {
transform: scale(2);
}
.Column--a {
color: inherit;
text-decoration: none;
}
.Column--a:hover {
text-decoration: underline;
}
@@ -0,0 +1,102 @@
import * as React from 'react';
import { Types, Maybe, timestamp } from '../../../common';
import { Node } from '../../../state';
import './Column.css';
import {
NameColumn,
ValidatorColumn,
LocationColumn,
ImplementationColumn,
NetworkIdColumn,
PeersColumn,
TxsColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
BlockNumberColumn,
BlockHashColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
BlockTimeColumn,
BlockPropagationColumn,
LastBlockColumn,
UptimeColumn,
NetworkStateColumn,
} from './';
export type Column =
| typeof NameColumn
| typeof ValidatorColumn
| typeof LocationColumn
| typeof ImplementationColumn
| typeof NetworkIdColumn
| typeof PeersColumn
| typeof TxsColumn
| typeof UploadColumn
| typeof DownloadColumn
| typeof StateCacheColumn
| typeof BlockNumberColumn
| typeof BlockHashColumn
| typeof FinalizedBlockColumn
| typeof FinalizedHashColumn
| typeof BlockTimeColumn
| typeof BlockPropagationColumn
| typeof LastBlockColumn
| typeof UptimeColumn
| typeof NetworkStateColumn;
export namespace Column {
export interface Props {
node: Node;
}
export function formatBytes(
bytes: number,
stamp: Maybe<Types.Timestamp>
): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB${ago}`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB${ago}`;
} else if (bytes >= 1000) {
return `${(bytes / 1024).toFixed(1)} kB${ago}`;
} else {
return `${bytes} B${ago}`;
}
}
export function formatBandwidth(
bps: number,
stamp: Maybe<Types.Timestamp>
): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bps >= 1024 * 1024) {
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`;
} else if (bps >= 1000) {
return `${(bps / 1024).toFixed(1)} kB/s${ago}`;
} else {
return `${bps | 0} B/s${ago}`;
}
}
}
export const BANDWIDTH_SCALE = 1024 * 1024;
function formatStamp(stamp: Types.Timestamp): string {
const passed = ((timestamp() - stamp) / 1000) | 0;
const hours = (passed / 3600) | 0;
const minutes = ((passed % 3600) / 60) | 0;
const seconds = passed % 60 | 0;
return hours
? `${hours}h ago`
: minutes
? `${minutes}m ago`
: `${seconds}s ago`;
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Types, Maybe, timestamp } from '../../../common';
import { Column, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/cloud-download.svg';
export class DownloadColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Download Bandwidth';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'download';
public static readonly sortBy = ({ download }: Node) =>
download.length < 3 ? 0 : download[download.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: Column.Props) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.download;
}
render() {
const { download, chartstamps } = this.props.node;
this.data = download;
if (download.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={Column.formatBandwidth}
values={download}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}
@@ -0,0 +1,27 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { formatNumber } from '../../../utils';
import icon from '../../../icons/cube-alt.svg';
export class FinalizedBlockColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Finalized Block';
public static readonly icon = icon;
public static readonly width = 88;
public static readonly setting = 'finalized';
public static readonly sortBy = ({ finalized }: Node) => finalized || 0;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.finalized;
}
render() {
const { finalized } = this.props.node;
this.data = finalized;
return <td className="Column">{`#${formatNumber(finalized)}`}</td>;
}
}
@@ -0,0 +1,47 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/file-binary.svg';
export class FinalizedHashColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Finalized Block Hash';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'finalizedhash';
public static readonly sortBy = ({ finalizedHash }: Node) =>
finalizedHash || '';
private data: Maybe<string>;
private copy: Maybe<Tooltip.CopyCallback>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.finalizedHash;
}
render() {
const { finalizedHash } = this.props.node;
this.data = finalizedHash;
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={finalizedHash} position="right" copy={this.onCopy} />
<Truncate text={finalizedHash} chars={16} />
</td>
);
}
private onCopy = (copy: Tooltip.CopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { Tooltip, Icon } from '../../';
import icon from '../../../icons/terminal.svg';
import parityPolkadotIcon from '../../../icons/dot.svg';
import paritySubstrateIcon from '../../../icons/substrate.svg';
import polkadotJsIcon from '../../../icons/polkadot-js.svg';
import airalabRobonomicsIcon from '../../../icons/robonomics.svg';
import chainXIcon from '../../../icons/chainx.svg';
import edgewareIcon from '../../../icons/edgeware.svg';
import joystreamIcon from '../../../icons/joystream.svg';
import ladderIcon from '../../../icons/laddernetwork.svg';
import cennznetIcon from '../../../icons/cennznet.svg';
import crabIcon from '../../../icons/crab.svg';
import darwiniaIcon from '../../../icons/darwinia.svg';
import turingIcon from '../../../icons/turingnetwork.svg';
import dothereumIcon from '../../../icons/dothereum.svg';
import katalchainIcon from '../../../icons/katalchain.svg';
import bifrostIcon from '../../../icons/bifrost.svg';
import totemIcon from '../../../icons/totem.svg';
import nodleIcon from '../../../icons/nodle.svg';
import zeroIcon from '../../../icons/zero.svg';
const ICONS = {
'parity-polkadot': parityPolkadotIcon,
'Parity Polkadot': parityPolkadotIcon,
'polkadot-js': polkadotJsIcon,
'airalab-robonomics': airalabRobonomicsIcon,
'substrate-node': paritySubstrateIcon,
'Substrate Node': paritySubstrateIcon,
'edgeware-node': edgewareIcon,
'Edgeware Node': edgewareIcon,
'joystream-node': joystreamIcon,
ChainX: chainXIcon,
'ladder-node': ladderIcon,
'cennznet-node': cennznetIcon,
'Darwinia Crab': crabIcon,
Darwinia: darwiniaIcon,
'turing-node': turingIcon,
dothereum: dothereumIcon,
katalchain: katalchainIcon,
'bifrost-node': bifrostIcon,
'totem-meccano-node': totemIcon,
Totem: totemIcon,
'Nodle Chain Node': nodleIcon,
subzero: zeroIcon,
};
const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
export class ImplementationColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Implementation';
public static readonly icon = icon;
public static readonly width = 90;
public static readonly setting = 'implementation';
public static readonly sortBy = ({ sortableVersion }: Node) =>
sortableVersion;
private implementation: string;
private version: string;
public shouldComponentUpdate(nextProps: Column.Props) {
if (this.props.node === nextProps.node) {
// Implementation can't change unless we got a new node
return false;
}
return (
this.implementation !== nextProps.node.implementation ||
this.version !== nextProps.node.version
);
}
render() {
const { implementation, version } = this.props.node;
this.implementation = implementation;
this.version = version;
const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?'];
const implIcon = ICONS[implementation] || paritySubstrateIcon;
return (
<td className="Column">
<Tooltip text={`${implementation} v${version}`} />
<Icon src={implIcon} /> {semver}
</td>
);
}
}
@@ -0,0 +1,32 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { Ago } from '../../';
import icon from '../../../icons/watch.svg';
export class LastBlockColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Last Block Time';
public static readonly icon = icon;
public static readonly width = 100;
public static readonly setting = 'blocklasttime';
public static readonly sortBy = ({ blockTimestamp }: Node) =>
blockTimestamp || 0;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.blockTimestamp;
}
render() {
const { blockTimestamp } = this.props.node;
this.data = blockTimestamp;
return (
<td className="Column">
<Ago when={blockTimestamp} />
</td>
);
}
}
@@ -0,0 +1,37 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/location.svg';
export class LocationColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Location';
public static readonly icon = icon;
public static readonly width = 140;
public static readonly setting = 'location';
public static readonly sortBy = ({ city }: Node) => city || '';
private data: Maybe<string>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.city;
}
render() {
const { city } = this.props.node;
this.data = city;
if (!city) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Tooltip text={city} position="left" />
<Truncate text={city} chars={14} />
</td>
);
}
}
@@ -0,0 +1,29 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/server.svg';
export class NameColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Node';
public static readonly icon = icon;
public static readonly setting = null;
public static readonly width = null;
public static readonly sortBy = ({ sortableName }: Node) => sortableName;
public shouldComponentUpdate(nextProps: Column.Props) {
// Node name only changes when the node does
return this.props.node !== nextProps.node;
}
render() {
const { name } = this.props.node;
return (
<td className="Column">
<Tooltip text={name} position="left" />
<Truncate text={name} />
</td>
);
}
}
@@ -0,0 +1,38 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { Truncate } from '../../';
import { Tooltip } from '../../';
import icon from '../../../icons/fingerprint.svg';
export class NetworkIdColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Network ID';
public static readonly icon = icon;
public static readonly width = 90;
public static readonly setting = 'networkId';
public static readonly sortBy = ({ networkId }: Node) => networkId || '';
private data: Maybe<string>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.networkId;
}
render() {
const { networkId } = this.props.node;
this.data = networkId;
if (!networkId) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Tooltip text={networkId} position="left" />
<Truncate text={networkId} chars={10} />
</td>
);
}
}
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { Icon } from '../../';
import icon from '../../../icons/network.svg';
import externalLinkIcon from '../../../icons/link-external.svg';
import { getHashData } from '../../../utils';
const URI_BASE =
window.location.protocol === 'https:'
? `/network_state/`
: `http://${window.location.hostname}:8000/network_state/`;
export class NetworkStateColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Network State';
public static readonly icon = icon;
public static readonly width = 16;
public static readonly setting = 'networkstate';
public static readonly sortBy = null;
public shouldComponentUpdate(nextProps: Column.Props) {
// Network state link changes when the node does
return this.props.node !== nextProps.node;
}
render() {
const { id } = this.props.node;
const chainLabel = getHashData().chain;
if (!chainLabel) {
return <td className="Column">-</td>;
}
const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`;
return (
<td className="Column">
<a className="Column--a" href={uri} target="_blank">
<Icon src={externalLinkIcon} />
</a>
</td>
);
}
}
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import icon from '../../../icons/broadcast.svg';
export class PeersColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Peer Count';
public static readonly icon = icon;
public static readonly width = 26;
public static readonly setting = 'peers';
public static readonly sortBy = ({ peers }: Node) => peers;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.peers;
}
render() {
const { peers } = this.props.node;
this.data = peers;
return <td className="Column">{peers}</td>;
}
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Types, Maybe, timestamp } from '../../../common';
import { Column, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/git-branch.svg';
export class StateCacheColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'State Cache Size';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'stateCacheSize';
public static readonly sortBy = ({ stateCacheSize }: Node) =>
stateCacheSize.length < 3 ? 0 : stateCacheSize[stateCacheSize.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: Column.Props) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.stateCacheSize;
}
render() {
const { stateCacheSize, chartstamps } = this.props.node;
this.data = stateCacheSize;
if (stateCacheSize.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={Column.formatBytes}
values={stateCacheSize}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import icon from '../../../icons/inbox.svg';
export class TxsColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Transactions in Queue';
public static readonly icon = icon;
public static readonly width = 26;
public static readonly setting = 'txs';
public static readonly sortBy = ({ txs }: Node) => txs;
private data = 0;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.txs;
}
render() {
const { txs } = this.props.node;
this.data = txs;
return <td className="Column">{txs}</td>;
}
}
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Types, Maybe, timestamp } from '../../../common';
import { Column, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/cloud-upload.svg';
export class UploadColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Upload Bandwidth';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'upload';
public static readonly sortBy = ({ upload }: Node) =>
upload.length < 3 ? 0 : upload[upload.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: Column.Props) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.upload;
}
render() {
const { upload, chartstamps } = this.props.node;
this.data = upload;
if (upload.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={Column.formatBandwidth}
values={upload}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import { Column } from './';
import { Node } from '../../../state';
import { Ago } from '../../';
import icon from '../../../icons/pulse.svg';
export class UptimeColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Node Uptime';
public static readonly icon = icon;
public static readonly width = 58;
public static readonly setting = 'uptime';
public static readonly sortBy = ({ connectedAt }: Node) => connectedAt || 0;
public shouldComponentUpdate(nextProps: Column.Props) {
// Uptime only changes when the node does
return this.props.node !== nextProps.node;
}
render() {
const { connectedAt } = this.props.node;
return (
<td className="Column">
<Ago when={connectedAt} justTime={true} />
</td>
);
}
}
@@ -0,0 +1,54 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { Column } from './';
import { Node } from '../../../state';
import { Tooltip, PolkadotIcon } from '../../';
import icon from '../../../icons/shield.svg';
export class ValidatorColumn extends React.Component<Column.Props, {}> {
public static readonly label = 'Validator';
public static readonly icon = icon;
public static readonly width = 16;
public static readonly setting = 'validator';
public static readonly sortBy = ({ validator }: Node) => validator || '';
private data: Maybe<string>;
private copy: Maybe<Tooltip.CopyCallback>;
public shouldComponentUpdate(nextProps: Column.Props) {
return this.data !== nextProps.node.validator;
}
render() {
const { validator } = this.props.node;
this.data = validator;
if (!validator) {
return <td className="Column">-</td>;
}
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={validator} copy={this.onCopy} />
<PolkadotIcon
className="Column-validator"
account={validator}
size={16}
/>
</td>
);
}
private onCopy = (copy: Tooltip.CopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}
@@ -0,0 +1,20 @@
export * from './Column';
export * from './NameColumn';
export * from './ValidatorColumn';
export * from './LocationColumn';
export * from './ImplementationColumn';
export * from './NetworkIdColumn';
export * from './PeersColumn';
export * from './TxsColumn';
export * from './UploadColumn';
export * from './DownloadColumn';
export * from './StateCacheColumn';
export * from './BlockNumberColumn';
export * from './BlockHashColumn';
export * from './FinalizedBlockColumn';
export * from './FinalizedHashColumn';
export * from './BlockTimeColumn';
export * from './BlockPropagationColumn';
export * from './LastBlockColumn';
export * from './UptimeColumn';
export * from './NetworkStateColumn';
+6 -8
View File
@@ -11,15 +11,13 @@
font-weight: 300; font-weight: 300;
} }
.List table { .List-padding {
padding: 0;
margin: 0;
}
.List--table {
width: 100%; width: 100%;
border-spacing: 0; border-spacing: 0;
}
.List thead {
background: #393838;
}
.List tbody {
font-family: monospace, sans-serif; font-family: monospace, sans-serif;
} }
+82 -19
View File
@@ -1,8 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { Types, Maybe } from '../../common'; import { Types, Maybe } from '../../common';
import { Filter } from '../'; import { Filter } from '../';
import { State as AppState, Node } from '../../state'; import { State as AppState, Update as AppUpdate, Node } from '../../state';
import { Row } from './'; import { Row, THead } from './';
import { Persistent, PersistentSet } from '../../persist'; import { Persistent, PersistentSet } from '../../persist';
import { viewport } from '../../utils'; import { viewport } from '../../utils';
@@ -16,6 +16,7 @@ import './List.css';
export namespace List { export namespace List {
export interface Props { export interface Props {
appState: Readonly<AppState>; appState: Readonly<AppState>;
appUpdate: AppUpdate;
pins: PersistentSet<Types.NodeName>; pins: PersistentSet<Types.NodeName>;
sortBy: Persistent<Maybe<number>>; sortBy: Persistent<Maybe<number>>;
} }
@@ -23,20 +24,24 @@ export namespace List {
export interface State { export interface State {
filter: Maybe<(node: Node) => boolean>; filter: Maybe<(node: Node) => boolean>;
viewportHeight: number; viewportHeight: number;
listStart: number;
listEnd: number;
} }
} }
// Helper for readability, used as `key` prop for each `Row`
// of the `List`, so that we can maximize re-using DOM elements.
type Key = number;
export class List extends React.Component<List.Props, {}> { export class List extends React.Component<List.Props, {}> {
public state = { public state = {
filter: null, filter: null,
viewportHeight: viewport().height, viewportHeight: viewport().height,
listStart: 0,
listEnd: 0,
}; };
private listStart = 0;
private listEnd = 0;
private relativeTop = -1; private relativeTop = -1;
private nextKey: Key = 0;
private previousKeys = new Map<Types.NodeId, Key>();
public componentDidMount() { public componentDidMount() {
this.onScroll(); this.onScroll();
@@ -53,7 +58,7 @@ export class List extends React.Component<List.Props, {}> {
public render() { public render() {
const { pins, sortBy, appState } = this.props; const { pins, sortBy, appState } = this.props;
const { selectedColumns } = appState; const { selectedColumns } = appState;
const { filter, listStart, listEnd } = this.state; const { filter } = this.state;
let nodes = appState.nodes.sorted(); let nodes = appState.nodes.sorted();
@@ -76,23 +81,26 @@ export class List extends React.Component<List.Props, {}> {
// to rendering view, so we put the whole list in focus // to rendering view, so we put the whole list in focus
appState.nodes.setFocus(0, nodes.length); appState.nodes.setFocus(0, nodes.length);
} else { } else {
appState.nodes.setFocus(listStart, listEnd); appState.nodes.setFocus(this.listStart, this.listEnd);
} }
const height = TH_HEIGHT + nodes.length * TR_HEIGHT; const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
const transform = `translateY(${listStart * TR_HEIGHT}px)`; const top = this.listStart * TR_HEIGHT;
nodes = nodes.slice(listStart, listEnd); nodes = nodes.slice(this.listStart, this.listEnd);
const keys = this.recalculateKeys(nodes);
return ( return (
<React.Fragment> <>
<div className="List" style={{ height }}> <div className="List" style={{ height }}>
<table> <table className="List--table">
<Row.HEADER columns={selectedColumns} sortBy={sortBy} /> <THead columns={selectedColumns} sortBy={sortBy} />
<tbody style={{ transform }}> <tbody>
{nodes.map((node) => ( <tr className="List-padding" style={{ height: `${top}px` }} />
{nodes.map((node, i) => (
<Row <Row
key={node.id} key={keys[i]}
node={node} node={node}
pins={pins} pins={pins}
columns={selectedColumns} columns={selectedColumns}
@@ -102,10 +110,63 @@ export class List extends React.Component<List.Props, {}> {
</table> </table>
</div> </div>
<Filter onChange={this.onFilterChange} /> <Filter onChange={this.onFilterChange} />
</React.Fragment> </>
); );
} }
// Get an array of keys for each `Node` in viewport in order.
//
// * If a `Node` was previously rendered, it will keep its `Key`.
//
// * If a `Node` is new to the viewport, it will get a `Key` of
// another `Node` that was removed from the viewport, or a new one.
private recalculateKeys(nodes: Array<Node>): Array<Key> {
// First we find all keys for `Node`s which didn't change from
// last render.
const keptKeys: Array<Maybe<Key>> = nodes.map(({ id }) => {
const key = this.previousKeys.get(id);
if (key != null) {
this.previousKeys.delete(id);
}
return key;
});
// Array of all unused keys
const unusedKeys = Array.from(this.previousKeys.values());
let search = 0;
// Clear the map so we can set new values
this.previousKeys.clear();
// Filling in blanks and re-populate previousKeys
return keptKeys.map((key: Maybe<Key>, i) => {
const id = nodes[i].id;
// `Node` was previously in viewport
if (key != null) {
this.previousKeys.set(id, key);
return key;
}
// Recycle the next unused key
if (search < unusedKeys.length) {
const unused = unusedKeys[search++];
this.previousKeys.set(id, unused);
return unused;
}
// No unused keys left, generate a new key
const newKey = this.nextKey++;
this.previousKeys.set(id, newKey);
return newKey;
});
}
private onScroll = () => { private onScroll = () => {
const relativeTop = divisibleBy( const relativeTop = divisibleBy(
window.scrollY - (HEADER + TR_HEIGHT), window.scrollY - (HEADER + TR_HEIGHT),
@@ -125,8 +186,10 @@ export class List extends React.Component<List.Props, {}> {
const listStart = Math.max(((top / TR_HEIGHT) | 0) - ROW_MARGIN, 0); const listStart = Math.max(((top / TR_HEIGHT) | 0) - ROW_MARGIN, 0);
const listEnd = listStart + ROW_MARGIN * 2 + Math.ceil(height / TR_HEIGHT); const listEnd = listStart + ROW_MARGIN * 2 + Math.ceil(height / TR_HEIGHT);
if (listStart !== this.state.listStart || listEnd !== this.state.listEnd) { if (listStart !== this.listStart || listEnd !== this.listEnd) {
this.setState({ listStart, listEnd }); this.listStart = listStart;
this.listEnd = listEnd;
this.props.appUpdate({});
} }
}; };
+1 -62
View File
@@ -3,56 +3,6 @@
cursor: pointer; cursor: pointer;
} }
.Row a {
color: inherit;
text-decoration: none;
}
.Row a:hover {
text-decoration: underline;
}
.Row-Header th,
.Row td {
text-align: left;
padding: 6px 13px;
height: 19px;
}
.Row td {
position: relative;
}
.Row-Header th {
height: 23px;
}
.Row-Header th.HeaderCell-sortable {
cursor: pointer;
}
.Row-Header th.HeaderCell-sorted {
cursor: pointer;
background: #e6007a;
color: #fff;
}
.Row .Row-truncate {
position: absolute;
left: 0;
right: 0;
top: 0;
padding: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Row .Row-Tooltip {
position: initial;
padding: inherit;
}
.Row-synced { .Row-synced {
color: #fff; color: #fff;
} }
@@ -72,20 +22,9 @@
} }
.Row-stale { .Row-stale {
font-style: italic; color: #555;
} }
.Row:hover { .Row:hover {
background-color: #1e1e1e; background-color: #1e1e1e;
} }
.Row-validator {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
}
.Row-validator:hover {
transform: scale(2);
}
+49 -66
View File
@@ -2,7 +2,28 @@ import * as React from 'react';
import { Types, Maybe } from '../../common'; import { Types, Maybe } from '../../common';
import { Node } from '../../state'; import { Node } from '../../state';
import { Persistent, PersistentSet } from '../../persist'; import { Persistent, PersistentSet } from '../../persist';
import { HeaderCell, Column } from './'; import {
Column,
NameColumn,
ValidatorColumn,
LocationColumn,
ImplementationColumn,
NetworkIdColumn,
PeersColumn,
TxsColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
BlockNumberColumn,
BlockHashColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
BlockTimeColumn,
BlockPropagationColumn,
LastBlockColumn,
UptimeColumn,
NetworkStateColumn,
} from './';
import './Row.css'; import './Row.css';
@@ -25,75 +46,41 @@ interface HeaderProps {
export class Row extends React.Component<Row.Props, Row.State> { export class Row extends React.Component<Row.Props, Row.State> {
public static readonly columns: Column[] = [ public static readonly columns: Column[] = [
Column.NAME, NameColumn,
Column.VALIDATOR, ValidatorColumn,
Column.LOCATION, LocationColumn,
Column.IMPLEMENTATION, ImplementationColumn,
Column.NETWORK_ID, NetworkIdColumn,
Column.PEERS, PeersColumn,
Column.TXS, TxsColumn,
Column.UPLOAD, UploadColumn,
Column.DOWNLOAD, DownloadColumn,
Column.STATE_CACHE, StateCacheColumn,
Column.BLOCK_NUMBER, BlockNumberColumn,
Column.BLOCK_HASH, BlockHashColumn,
Column.FINALIZED, FinalizedBlockColumn,
Column.FINALIZED_HASH, FinalizedHashColumn,
Column.BLOCK_TIME, BlockTimeColumn,
Column.BLOCK_PROPAGATION, BlockPropagationColumn,
Column.BLOCK_LAST_TIME, LastBlockColumn,
Column.UPTIME, UptimeColumn,
Column.NETWORK_STATE, NetworkStateColumn,
]; ];
public static HEADER = (props: HeaderProps) => { private renderedChangeRef = 0;
const { columns, sortBy } = props;
const last = columns.length - 1;
return ( public shouldComponentUpdate(nextProps: Row.Props): boolean {
<thead>
<tr className="Row-Header">
{columns.map((col, index) => (
<HeaderCell
key={index}
column={col}
index={index}
last={last}
sortBy={sortBy}
/>
))}
</tr>
</thead>
);
};
public state = { update: 0 };
public componentDidMount() {
const { node } = this.props;
node.subscribe(this.onUpdate);
}
public componentWillUnmount() {
const { node } = this.props;
node.unsubscribe(this.onUpdate);
}
public shouldComponentUpdate(
nextProps: Row.Props,
nextState: Row.State
): boolean {
return ( return (
this.props.node.id !== nextProps.node.id || this.props.node.id !== nextProps.node.id ||
this.state.update !== nextState.update this.renderedChangeRef !== nextProps.node.changeRef
); );
} }
public render() { public render() {
const { node, columns } = this.props; const { node, columns } = this.props;
this.renderedChangeRef = node.changeRef;
let className = 'Row'; let className = 'Row';
if (node.propagationTime != null) { if (node.propagationTime != null) {
@@ -110,9 +97,9 @@ export class Row extends React.Component<Row.Props, Row.State> {
return ( return (
<tr className={className} onClick={this.toggle}> <tr className={className} onClick={this.toggle}>
{columns.map(({ render }, index) => ( {columns.map((col, index) =>
<td key={index}>{render(node)}</td> React.createElement(col, { node, key: index })
))} )}
</tr> </tr>
); );
} }
@@ -126,8 +113,4 @@ export class Row extends React.Component<Row.Props, Row.State> {
pins.add(node.name); pins.add(node.name);
} }
}; };
private onUpdate = () => {
this.setState({ update: this.state.update + 1 });
};
} }
+24
View File
@@ -0,0 +1,24 @@
.THead {
background: #393838;
}
.THeadCell {
text-align: left;
padding: 6px 13px;
height: 23px;
}
.THeadCell-sortable {
cursor: pointer;
}
.THeadCell-sorted {
cursor: pointer;
background: #e6007a;
color: #fff;
}
.THeadCell-container {
position: relative;
display: inline-block;
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Maybe } from '../../common';
import { Column, THeadCell } from './';
import { Persistent } from '../../persist';
import './THead.css';
export namespace THead {
export interface Props {
columns: Column[];
sortBy: Persistent<Maybe<number>>;
}
}
export class THead extends React.Component<THead.Props, {}> {
private sortBy: Maybe<number>;
constructor(props: THead.Props) {
super(props);
this.sortBy = props.sortBy.get();
}
public shouldComponentUpdate(nextProps: THead.Props) {
return this.sortBy !== nextProps.sortBy.get();
}
public render() {
const { columns, sortBy } = this.props;
const last = columns.length - 1;
this.sortBy = sortBy.get();
return (
<thead>
<tr className="THead">
{columns.map((col, index) => (
<THeadCell
key={index}
column={col}
index={index}
last={last}
sortBy={sortBy}
/>
))}
</tr>
</thead>
);
}
}
@@ -7,7 +7,7 @@ import { Persistent } from '../../persist';
import sortAscIcon from '../../icons/triangle-up.svg'; import sortAscIcon from '../../icons/triangle-up.svg';
import sortDescIcon from '../../icons/triangle-down.svg'; import sortDescIcon from '../../icons/triangle-down.svg';
export namespace HeaderCell { export namespace THeadCell {
export interface Props { export interface Props {
column: Column; column: Column;
index: number; index: number;
@@ -16,7 +16,7 @@ export namespace HeaderCell {
} }
} }
export class HeaderCell extends React.Component<HeaderCell.Props, {}> { export class THeadCell extends React.Component<THeadCell.Props, {}> {
public render() { public render() {
const { column, index, last } = this.props; const { column, index, last } = this.props;
const { icon, width, label } = column; const { icon, width, label } = column;
@@ -25,10 +25,10 @@ export class HeaderCell extends React.Component<HeaderCell.Props, {}> {
const sortBy = this.props.sortBy.get(); const sortBy = this.props.sortBy.get();
const className = const className =
column.sortBy == null column.sortBy == null
? '' ? 'THeadCell'
: sortBy === index || sortBy === ~index : sortBy === index || sortBy === ~index
? 'HeaderCell-sorted' ? 'THeadCell THeadCell-sorted'
: 'HeaderCell-sortable'; : 'THeadCell THeadCell-sortable';
const i = const i =
sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon; sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon;
@@ -38,9 +38,10 @@ export class HeaderCell extends React.Component<HeaderCell.Props, {}> {
style={width ? { width } : undefined} style={width ? { width } : undefined}
onClick={this.toggleSort} onClick={this.toggleSort}
> >
<Tooltip text={label} inline={true} position={position}> <span className="THeadCell-container">
<Tooltip text={label} position={position} />
<Icon src={i} /> <Icon src={i} />
</Tooltip> </span>
</th> </th>
); );
} }
-38
View File
@@ -1,38 +0,0 @@
import * as React from 'react';
import { Tooltip } from '../';
export namespace Truncate {
export interface Props {
text: string;
copy?: boolean;
position?: Tooltip.Props['position'];
}
}
export class Truncate extends React.Component<Truncate.Props, {}> {
public render() {
const { text, position, copy } = this.props;
if (!text) {
return '-';
}
return (
<Tooltip
text={text}
position={position}
copy={copy}
className="Row-Tooltip"
>
<div className="Row-truncate">{text}</div>
</Tooltip>
);
}
public shouldComponentUpdate(nextProps: Truncate.Props): boolean {
return (
this.props.text !== nextProps.text ||
this.props.position !== nextProps.position
);
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
export * from './Column'; export * from './Column';
export * from './List'; export * from './List';
export * from './Truncate';
export * from './Row'; export * from './Row';
export * from './HeaderCell'; export * from './THeadCell';
export * from './THead';
+17 -17
View File
@@ -97,7 +97,7 @@ export class Location extends React.Component<Location.Props, Location.State> {
validatorRow = ( validatorRow = (
<tr> <tr>
<td> <td>
<Icon src={nodeValidatorIcon} alt="Node" /> <Icon src={nodeValidatorIcon} />
</td> </td>
<td colSpan={5}> <td colSpan={5}>
{trimHash(validator, 30)} {trimHash(validator, 30)}
@@ -113,53 +113,53 @@ export class Location extends React.Component<Location.Props, Location.State> {
<table className="Location-details Location-details"> <table className="Location-details Location-details">
<tbody> <tbody>
<tr> <tr>
<td> <td title="Node">
<Icon src={nodeIcon} alt="Node" /> <Icon src={nodeIcon} />
</td> </td>
<td colSpan={5}>{name}</td> <td colSpan={5}>{name}</td>
</tr> </tr>
{validatorRow} {validatorRow}
<tr> <tr>
<td> <td title="Implementation">
<Icon src={nodeTypeIcon} alt="Implementation" /> <Icon src={nodeTypeIcon} />
</td> </td>
<td colSpan={5}> <td colSpan={5}>
{implementation} v{version} {implementation} v{version}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td title="Location">
<Icon src={nodeLocationIcon} alt="Location" /> <Icon src={nodeLocationIcon} />
</td> </td>
<td colSpan={5}>{city}</td> <td colSpan={5}>{city}</td>
</tr> </tr>
<tr> <tr>
<td> <td title="Block">
<Icon src={blockIcon} alt="Block" /> <Icon src={blockIcon} />
</td> </td>
<td colSpan={5}>#{formatNumber(height)}</td> <td colSpan={5}>#{formatNumber(height)}</td>
</tr> </tr>
<tr> <tr>
<td> <td title="Block Hash">
<Icon src={blockHashIcon} alt="Block Hash" /> <Icon src={blockHashIcon} />
</td> </td>
<td colSpan={5}>{trimHash(hash, 20)}</td> <td colSpan={5}>{trimHash(hash, 20)}</td>
</tr> </tr>
<tr> <tr>
<td> <td title="Block Time">
<Icon src={blockTimeIcon} alt="Block Time" /> <Icon src={blockTimeIcon} />
</td> </td>
<td style={{ width: 80 }}> <td style={{ width: 80 }}>
{secondsWithPrecision(blockTime / 1000)} {secondsWithPrecision(blockTime / 1000)}
</td> </td>
<td> <td title="Block Propagation Time">
<Icon src={propagationTimeIcon} alt="Block Propagation Time" /> <Icon src={propagationTimeIcon} />
</td> </td>
<td style={{ width: 58 }}> <td style={{ width: 58 }}>
{propagationTime == null ? '∞' : milliOrSecond(propagationTime)} {propagationTime == null ? '∞' : milliOrSecond(propagationTime)}
</td> </td>
<td> <td title="Last Block Time">
<Icon src={lastTimeIcon} alt="Last Block Time" /> <Icon src={lastTimeIcon} />
</td> </td>
<td style={{ minWidth: 82 }}> <td style={{ minWidth: 82 }}>
<Ago when={blockTimestamp} /> <Ago when={blockTimestamp} />
+1 -1
View File
@@ -34,7 +34,7 @@ export class Map extends React.Component<Map.Props, Map.State> {
left: 0, left: 0,
}; };
public componentWillMount() { public componentDidMount() {
this.onResize(); this.onResize();
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
+7 -4
View File
@@ -19,14 +19,17 @@ export function OfflineIndicator(
return null; return null;
case 'offline': case 'offline':
return ( return (
<div className="OfflineIndicator"> <div className="OfflineIndicator" title="Offline">
<Icon src={offlineIcon} alt="Offline" /> <Icon src={offlineIcon} />
</div> </div>
); );
case 'upgrade-requested': case 'upgrade-requested':
return ( return (
<div className="OfflineIndicator OfflineIndicator-upgrade"> <div
<Icon src={upgradeIcon} alt="New Version Available" /> className="OfflineIndicator OfflineIndicator-upgrade"
title="New Version Available"
>
<Icon src={upgradeIcon} />
</div> </div>
); );
} }
+52 -22
View File
@@ -8,6 +8,7 @@
// https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx // https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx
import * as React from 'react'; import * as React from 'react';
import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto'; import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto';
import { getSVGShadowRoot, W3SVG } from '../utils';
interface Circle { interface Circle {
cx: number; cx: number;
@@ -172,20 +173,19 @@ function getColors(address: string): string[] {
/** /**
* @description Generate a array of the circles that make up an indenticon * @description Generate a array of the circles that make up an indenticon
*/ */
export default function generate( function generate(address: string, isSixPoint = false): Circle[] {
address: string,
isSixPoint = false
): Circle[] {
const colors = getColors(address); const colors = getColors(address);
return [OUTER_CIRCLE].concat( return [OUTER_CIRCLE].concat(
getCircleXY(isSixPoint).map( getCircleXY(isSixPoint).map(
([cx, cy], index): Circle => ({ ([cx, cy], index): Circle => {
cx, return {
cy, cx,
r: Z, cy,
fill: colors[index], r: Z,
}) fill: colors[index],
};
}
) )
); );
} }
@@ -194,24 +194,54 @@ export namespace PolkadotIcon {
export interface Props { export interface Props {
account: string; account: string;
size: number; size: number;
className?: string;
}
}
const rendered = new Set<string>();
// Lazily render the icon in the DOM, so that we can referenced
// it by id using shadow DOM.
function renderShadowIcon(account: string) {
if (!rendered.has(account)) {
rendered.add(account);
const symEl = document.createElementNS(W3SVG, 'symbol');
symEl.setAttribute('id', account);
symEl.setAttribute('viewBox', '0 0 64 64');
generate(account, false).forEach(({ cx, cy, r, fill }) => {
const circle = document.createElementNS(W3SVG, 'circle');
circle.setAttribute('cx', String(cx));
circle.setAttribute('cy', String(cy));
circle.setAttribute('r', String(r));
circle.setAttribute('fill', fill);
symEl.appendChild(circle);
});
getSVGShadowRoot().appendChild(symEl);
} }
} }
export class PolkadotIcon extends React.Component<PolkadotIcon.Props, {}> { export class PolkadotIcon extends React.Component<PolkadotIcon.Props, {}> {
public render(): React.ReactNode { public shouldComponentUpdate(nextProps: PolkadotIcon.Props) {
const { account, size } = this.props;
return ( return (
<svg width={size} height={size} viewBox="0 0 64 64"> this.props.account !== nextProps.account ||
{generate(account, false).map(this.renderCircle)} this.props.size !== nextProps.size
</svg>
); );
} }
private renderCircle = ( public render(): React.ReactNode {
{ cx, cy, r, fill }: Circle, const { account, size, className } = this.props;
key: number renderShadowIcon(account);
): React.ReactNode => {
return <circle key={key} cx={cx} cy={cy} r={r} fill={fill} />; return (
}; <svg width={size} height={size} className={className}>
<use href={`#${account}`} />
</svg>
);
}
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export class Setting extends React.Component<Setting.Props, {}> {
return ( return (
<div className={className} onClick={this.toggle}> <div className={className} onClick={this.toggle}>
<Icon src={icon} alt={label} /> <Icon src={icon} />
{label} {label}
<span className="Setting-switch"> <span className="Setting-switch">
<span className="Setting-knob" /> <span className="Setting-knob" />
+12
View File
@@ -4,3 +4,15 @@
stroke: currentcolor; stroke: currentcolor;
margin: 0 -1px -3px -1px; margin: 0 -1px -3px -1px;
} }
.Sparkline path {
pointer-events: none;
}
.Sparkline .Sparkline-cursor {
display: none;
}
.Sparkline:hover .Sparkline-cursor {
display: initial;
}
+61 -49
View File
@@ -1,6 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Types, Maybe } from '../common'; import { Types, Maybe } from '../common';
import sparkline from '@fnando/sparkline';
import { Tooltip } from './'; import { Tooltip } from './';
import './Sparkline.css'; import './Sparkline.css';
@@ -18,60 +17,64 @@ export namespace Sparkline {
} }
export class Sparkline extends React.Component<Sparkline.Props, {}> { export class Sparkline extends React.Component<Sparkline.Props, {}> {
private el: SVGSVGElement; private cursor: SVGPathElement;
private update: Tooltip.UpdateCallback; private update: Tooltip.UpdateCallback;
public componentDidMount() {
sparkline(this.el, this.props.values, {
spotRadius: 0.1,
minScale: this.props.minScale,
interactive: true,
onmousemove: this.onMouseMove,
});
}
public shouldComponentUpdate(nextProps: Sparkline.Props): boolean { public shouldComponentUpdate(nextProps: Sparkline.Props): boolean {
const { stroke, width, height, minScale, format } = this.props; const { stroke, width, height, minScale, format, values } = this.props;
if ( return (
values !== nextProps.values ||
stroke !== nextProps.stroke || stroke !== nextProps.stroke ||
width !== nextProps.width || width !== nextProps.width ||
height !== nextProps.height || height !== nextProps.height ||
format !== nextProps.format format !== nextProps.format
) {
return true;
}
if (this.props.values !== nextProps.values) {
sparkline(this.el, nextProps.values, {
spotRadius: 0.1,
minScale,
interactive: true,
onmousemove: this.onMouseMove,
});
}
return false;
}
public render() {
const { stroke, width, height } = this.props;
return (
<Tooltip text="-" onInit={this.onTooltipInit}>
<svg
className="Sparkline"
ref={this.onRef}
width={width}
height={height}
strokeWidth={stroke}
/>
</Tooltip>
); );
} }
private onRef = (el: SVGSVGElement) => { public render() {
this.el = el; const { stroke, width, height, minScale, values } = this.props;
const padding = stroke / 2;
const paddedHeight = height - padding;
const paddedWidth = width - 2;
const max = Math.max(minScale || 0, ...values);
const offset = paddedWidth / (values.length - 1);
let path = '';
values.forEach((value, index) => {
const x = 1 + index * offset;
const y = padding + (1 - value / max) * paddedHeight;
if (path) {
path += ` L ${x} ${y}`;
} else {
path = `${x} ${y}`;
}
});
return (
<>
<Tooltip text="-" onInit={this.onTooltipInit} />
<svg
className="Sparkline"
width={width}
height={height}
strokeWidth={stroke}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
<path d={`M 0 ${height} L ${path} V ${height} Z`} stroke="none" />
<path d={`M ${path}`} fill="none" />
<path className="Sparkline-cursor" strokeWidth="2" ref={this.onRef} />
</svg>
</>
);
}
private onRef = (cursor: SVGPathElement) => {
this.cursor = cursor;
}; };
private onTooltipInit = (update: Tooltip.UpdateCallback) => { private onTooltipInit = (update: Tooltip.UpdateCallback) => {
@@ -79,13 +82,22 @@ export class Sparkline extends React.Component<Sparkline.Props, {}> {
}; };
private onMouseMove = ( private onMouseMove = (
_event: MouseEvent, event: React.MouseEvent<SVGSVGElement, MouseEvent>
data: { value: number; index: number }
) => { ) => {
const { format, stamps } = this.props; const { width, height, values, format, stamps } = this.props;
const offset = (width - 2) / (values.length - 1);
const cur =
Math.round((event.nativeEvent.offsetX - 1 - offset / 2) / offset) | 0;
this.cursor.setAttribute('d', `M ${1 + offset * cur} 0 V ${height}`);
const str = format const str = format
? format(data.value, stamps ? stamps[data.index] : null) ? format(values[cur], stamps ? stamps[cur] : null)
: `${data.value}`; : `${values[cur]}`;
this.update(str); this.update(str);
}; };
private onMouseLeave = () => {
this.cursor.removeAttribute('d');
};
} }
+1 -1
View File
@@ -13,7 +13,7 @@ export namespace Tile {
export function Tile(props: Tile.Props) { export function Tile(props: Tile.Props) {
return ( return (
<div className="Tile"> <div className="Tile">
<Icon src={props.icon} alt={props.title} /> <Icon src={props.icon} />
<span className="Tile-label">{props.title}</span> <span className="Tile-label">{props.title}</span>
<span className="Tile-content">{props.children}</span> <span className="Tile-content">{props.children}</span>
</div> </div>
+1 -11
View File
@@ -74,17 +74,7 @@
right: 0; right: 0;
} }
.Tooltip-container:hover .Tooltip { :hover > .Tooltip {
display: block; display: block;
animation: show 0.15s forwards;
} }
@keyframes show {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+31 -27
View File
@@ -1,12 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { Maybe } from '../common';
import './Tooltip.css'; import './Tooltip.css';
export namespace Tooltip { export namespace Tooltip {
export interface Props { export interface Props {
text: string; text: string;
copy?: boolean; copy?: (cb: CopyCallback) => void;
inline?: boolean;
className?: string; className?: string;
position?: 'left' | 'right' | 'center'; position?: 'left' | 'right' | 'center';
onInit?: (update: UpdateCallback) => void; onInit?: (update: UpdateCallback) => void;
@@ -17,6 +17,7 @@ export namespace Tooltip {
} }
export type UpdateCallback = (text: string) => void; export type UpdateCallback = (text: string) => void;
export type CopyCallback = Maybe<() => void>;
} }
function copyToClipboard(text: string) { function copyToClipboard(text: string) {
@@ -32,33 +33,42 @@ export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
public state = { copied: false }; public state = { copied: false };
private el: HTMLDivElement; private el: HTMLDivElement;
private timer: NodeJS.Timer; private timer: NodeJS.Timer | null = null;
public componentDidMount() { public componentDidMount() {
if (this.props.onInit) { if (this.props.onInit) {
this.props.onInit(this.update); this.props.onInit(this.update);
} }
if (this.props.copy) {
this.props.copy(this.onClick);
}
} }
public componentWillUnmount() { public componentWillUnmount() {
clearTimeout(this.timer); if (this.timer) {
clearTimeout(this.timer);
}
if (this.props.copy) {
this.props.copy(null);
}
}
public shouldComponentUpdate(
nextProps: Tooltip.Props,
nextState: Tooltip.State
) {
return (
this.props.text !== nextProps.text ||
this.state.copied !== nextState.copied
);
} }
public render() { public render() {
const { text, inline, className, position } = this.props; const { text, className, position } = this.props;
const { copied } = this.state; const { copied } = this.state;
let containerClass = 'Tooltip-container';
let tooltipClass = 'Tooltip'; let tooltipClass = 'Tooltip';
if (className) {
containerClass += ' ' + className;
}
if (inline) {
containerClass += ' Tooltip-container-inline';
}
if (position && position !== 'center') { if (position && position !== 'center') {
tooltipClass += ` Tooltip-${position}`; tooltipClass += ` Tooltip-${position}`;
} }
@@ -68,11 +78,8 @@ export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
} }
return ( return (
<div className={containerClass} onClick={this.onClick}> <div className={tooltipClass} ref={this.onRef}>
<div className={tooltipClass} ref={this.onRef}> {copied ? 'Copied to clipboard!' : text}
{copied ? 'Copied to clipboard!' : text}
</div>
{this.props.children}
</div> </div>
); );
} }
@@ -85,16 +92,12 @@ export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
this.el.textContent = text; this.el.textContent = text;
}; };
private onClick = (event: React.MouseEvent<HTMLDivElement>) => { private onClick = () => {
if (this.props.copy !== true) {
return;
}
copyToClipboard(this.props.text); copyToClipboard(this.props.text);
event.stopPropagation(); if (this.timer) {
clearTimeout(this.timer);
clearTimeout(this.timer); }
this.setState({ copied: true }); this.setState({ copied: true });
this.timer = setTimeout(this.restore, 2000); this.timer = setTimeout(this.restore, 2000);
@@ -102,5 +105,6 @@ export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
private restore = () => { private restore = () => {
this.setState({ copied: false }); this.setState({ copied: false });
this.timer = null;
}; };
} }
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
export namespace Truncate {
export interface Props {
text: string;
chars?: number;
}
}
export class Truncate extends React.Component<Truncate.Props, {}> {
public shouldComponentUpdate(nextProps: Truncate.Props): boolean {
return this.props.text !== nextProps.text;
}
public render() {
const { text, chars } = this.props;
if (!text) {
return '-';
}
if (chars != null && text.length <= chars) {
return text;
}
return chars ? (
`${text.substr(0, chars - 1)}`
) : (
<div className="Column-truncate">{text}</div>
);
}
}
+1
View File
@@ -10,6 +10,7 @@ export * from './Tile';
export * from './Ago'; export * from './Ago';
export * from './OfflineIndicator'; export * from './OfflineIndicator';
export * from './Sparkline'; export * from './Sparkline';
export * from './Truncate';
export * from './Tooltip'; export * from './Tooltip';
export * from './Filter'; export * from './Filter';
export * from './PolkadotIcon'; export * from './PolkadotIcon';
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

+3 -6
View File
@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="512" height="512" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<!-- Generator: Sketch 64 (93537) - https://sketch.com --> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<title>Darwinia</title> <path d="M113.288481,145 C128.136677,145 140.174312,157.023943 140.174312,171.856735 C140.174312,183.469807 132.793886,193.362319 122.463232,197.108867 L122.464671,336.232914 C141.696857,335.384773 159.580699,331.869555 175.47006,326.043141 L175.46949,219.38341 C165.138836,215.636862 157.75841,205.744351 157.75841,194.131279 C157.75841,179.298487 169.796045,167.274543 184.644241,167.274543 C199.492436,167.274543 211.530071,179.298487 211.530071,194.131279 C211.530071,205.744351 204.149645,215.636862 193.818991,219.38341 L193.819851,317.813403 C211.486095,308.273137 225.639172,295.360902 235.02445,279.767728 C228.656572,274.035269 224.654434,265.734349 224.654434,256.5 C224.654434,239.206326 238.688237,225.188356 256,225.188356 C257.421586,225.188356 258.821069,225.282882 260.192492,225.465984 C284.066647,183.99976 335.257967,158.88464 396.335714,158.249774 L398.711519,158.237443 L496.825688,158.237443 C501.892521,158.237443 506,162.340477 506,167.401826 C506,172.282413 502.180673,176.271914 497.364749,176.550653 L496.825688,176.56621 L479.24159,176.56621 L479.242252,315.890998 C489.573026,319.637265 496.953109,329.529235 496.953109,341.143265 C496.953109,355.974833 484.914915,368 470.067278,368 C455.219642,368 443.181448,355.974833 443.181448,341.143265 C443.181448,329.528083 450.562994,319.635304 460.895378,315.889884 L460.892966,176.56621 L407.883792,176.56621 L407.886493,315.890998 C418.217267,319.637265 425.59735,329.529235 425.59735,341.143265 C425.59735,355.974833 413.559155,368 398.711519,368 C383.863882,368 371.825688,355.974833 371.825688,341.143265 C371.825688,329.529235 379.205771,319.637265 389.536545,315.890998 L389.535329,176.767086 C370.303143,177.615227 352.419301,181.130445 336.52994,186.956859 L336.530733,293.616455 C346.861507,297.362722 354.24159,307.254692 354.24159,318.868721 C354.24159,333.70029 342.203396,345.725457 327.355759,345.725457 C312.508123,345.725457 300.469929,333.70029 300.469929,318.868721 C300.469929,307.254692 307.850012,297.362722 318.180786,293.616455 L318.180149,195.186597 C300.513905,204.726863 286.360828,217.639098 276.97555,233.232272 C283.34359,238.964127 287.345566,247.265003 287.345566,256.5 C287.345566,273.79246 273.311194,287.811644 256,287.811644 C254.57846,287.811644 253.179017,287.71711 251.807626,287.533993 C227.933663,329.000088 176.742218,354.115358 115.664286,354.750226 L113.288481,354.762557 L15.1743119,354.762557 C10.1074794,354.762557 6,350.659523 6,345.598174 C6,340.717587 9.81932711,336.728086 14.6352514,336.449347 L15.1743119,336.43379 L32.7584098,336.432772 L32.7584368,197.109036 C22.4275387,193.362616 15.0468909,183.469982 15.0468909,171.856735 C15.0468909,157.023943 27.0845259,145 41.9327217,145 C56.7809176,145 68.8185525,157.023943 68.8185525,171.856735 C68.8185525,183.468835 61.439362,193.360663 51.1100663,197.107926 L51.1070336,336.432772 L104.11315,336.432772 L104.113731,197.108867 C93.7830763,193.362319 86.4026504,183.469807 86.4026504,171.856735 C86.4026504,157.023943 98.4402853,145 113.288481,145 Z" fill="currentColor"></path>
<desc>Darwinia Network Logo</desc>
<g id="Darwinia" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M113.288481,145 C128.136677,145 140.174312,157.023943 140.174312,171.856735 C140.174312,183.469807 132.793886,193.362319 122.463232,197.108867 L122.464671,336.232914 C141.696857,335.384773 159.580699,331.869555 175.47006,326.043141 L175.46949,219.38341 C165.138836,215.636862 157.75841,205.744351 157.75841,194.131279 C157.75841,179.298487 169.796045,167.274543 184.644241,167.274543 C199.492436,167.274543 211.530071,179.298487 211.530071,194.131279 C211.530071,205.744351 204.149645,215.636862 193.818991,219.38341 L193.819851,317.813403 C211.486095,308.273137 225.639172,295.360902 235.02445,279.767728 C228.656572,274.035269 224.654434,265.734349 224.654434,256.5 C224.654434,239.206326 238.688237,225.188356 256,225.188356 C257.421586,225.188356 258.821069,225.282882 260.192492,225.465984 C284.066647,183.99976 335.257967,158.88464 396.335714,158.249774 L398.711519,158.237443 L496.825688,158.237443 C501.892521,158.237443 506,162.340477 506,167.401826 C506,172.282413 502.180673,176.271914 497.364749,176.550653 L496.825688,176.56621 L479.24159,176.56621 L479.242252,315.890998 C489.573026,319.637265 496.953109,329.529235 496.953109,341.143265 C496.953109,355.974833 484.914915,368 470.067278,368 C455.219642,368 443.181448,355.974833 443.181448,341.143265 C443.181448,329.528083 450.562994,319.635304 460.895378,315.889884 L460.892966,176.56621 L407.883792,176.56621 L407.886493,315.890998 C418.217267,319.637265 425.59735,329.529235 425.59735,341.143265 C425.59735,355.974833 413.559155,368 398.711519,368 C383.863882,368 371.825688,355.974833 371.825688,341.143265 C371.825688,329.529235 379.205771,319.637265 389.536545,315.890998 L389.535329,176.767086 C370.303143,177.615227 352.419301,181.130445 336.52994,186.956859 L336.530733,293.616455 C346.861507,297.362722 354.24159,307.254692 354.24159,318.868721 C354.24159,333.70029 342.203396,345.725457 327.355759,345.725457 C312.508123,345.725457 300.469929,333.70029 300.469929,318.868721 C300.469929,307.254692 307.850012,297.362722 318.180786,293.616455 L318.180149,195.186597 C300.513905,204.726863 286.360828,217.639098 276.97555,233.232272 C283.34359,238.964127 287.345566,247.265003 287.345566,256.5 C287.345566,273.79246 273.311194,287.811644 256,287.811644 C254.57846,287.811644 253.179017,287.71711 251.807626,287.533993 C227.933663,329.000088 176.742218,354.115358 115.664286,354.750226 L113.288481,354.762557 L15.1743119,354.762557 C10.1074794,354.762557 6,350.659523 6,345.598174 C6,340.717587 9.81932711,336.728086 14.6352514,336.449347 L15.1743119,336.43379 L32.7584098,336.432772 L32.7584368,197.109036 C22.4275387,193.362616 15.0468909,183.469982 15.0468909,171.856735 C15.0468909,157.023943 27.0845259,145 41.9327217,145 C56.7809176,145 68.8185525,157.023943 68.8185525,171.856735 C68.8185525,183.468835 61.439362,193.360663 51.1100663,197.107926 L51.1070336,336.432772 L104.11315,336.432772 L104.113731,197.108867 C93.7830763,193.362319 86.4026504,183.469807 86.4026504,171.856735 C86.4026504,157.023943 98.4402853,145 113.288481,145 Z" id="Darwinia" fill="currentColor"></path>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+6 -6
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="200px" height="200px" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="200" height="200" viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="Artboard" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="square"> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="square">
<g id="Group" transform="translate(48.000000, 40.000000)" stroke="currentColor" stroke-width="22"> <g transform="translate(48.000000, 40.000000)" stroke="currentColor" stroke-width="22">
<path d="M52.5,0.5 L52.5,120.5" id="Line"></path> <path d="M52.5,0.5 L52.5,120.5"></path>
<path d="M52.5,0.5 L52.5,120.5" id="Line" transform="translate(52.500000, 60.500000) rotate(60.000000) translate(-52.500000, -60.500000) "></path> <path d="M52.5,0.5 L52.5,120.5" transform="translate(52.500000, 60.500000) rotate(60.000000) translate(-52.500000, -60.500000) "></path>
<path d="M52.5,0.5 L52.5,120.5" id="Line" transform="translate(52.500000, 60.500000) rotate(-60.000000) translate(-52.500000, -60.500000) "></path> <path d="M52.5,0.5 L52.5,120.5" transform="translate(52.500000, 60.500000) rotate(-60.000000) translate(-52.500000, -60.500000) "></path>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 814 B

After

Width:  |  Height:  |  Size: 712 B

+1 -1
View File
@@ -1 +1 @@
<?xml version="1.0" encoding="iso-8859-1" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="polkadot-js" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;zoom: 1;" xml:space="preserve"><circle style="fill:currentColor;opacity:0.25" cx="256" cy="256" r="200"></circle><rect stroke="currentColor" height="400" x="80" stroke-width="0" width="400" y="48" fill="none" transform=""></rect><path style="fill:currentColor;" d="M391.05,232.97 c-7.125,-20.664 -36.341,-20.664 -42.04,-3.563 c-5.7,17.101 13.539,27.076 43.466,37.053 c29.927,9.976 48.454,36.341 44.891,59.854 s-13.539,54.154 -72.68,54.154 c-36.963,0 -57.505,-23.103 -67.714,-40.429 l33.511,-20.138 c0,0 11.401,24.227 32.777,24.227 c21.377,0 29.927,-7.125 29.927,-23.514 c0,-19.951 -69.118,-27.076 -79.806,-61.279 s3.563,-82.656 53.441,-79.094 c31.175,2.227 48.71,17.535 57.478,28.703 L391.05,232.97 z " visibility="visible"></path><path style="fill:currentColor;" d="M235.714,183.805 c0,0 0,120.421 0,136.81 c0,16.388 -14.251,24.94 -28.502,22.089 c-14.251,-2.85 -20.664,-19.951 -20.664,-19.951 l-32.777,22.089 c0,0 7.125,32.777 52.016,35.628 c44.891,2.85 70.542,-24.227 70.542,-47.029 s0,-149.636 0,-149.636 L235.714,183.805 L235.714,183.805 z " visibility="visible"></path><circle style="fill:currentColor;" cx="90" cy="180" r="64"></circle></svg> <?xml version="1.0" encoding="iso-8859-1" standalone="yes"?><svg xmlns="http://www.w3.org/2000/svg" x="0" y="0" viewBox="0 0 512 512"><circle style="fill:currentColor;opacity:0.25" cx="256" cy="256" r="200"></circle><rect stroke="currentColor" height="400" x="80" stroke-width="0" width="400" y="48" fill="none" transform=""></rect><path style="fill:currentColor;" d="M391.05,232.97 c-7.125,-20.664 -36.341,-20.664 -42.04,-3.563 c-5.7,17.101 13.539,27.076 43.466,37.053 c29.927,9.976 48.454,36.341 44.891,59.854 s-13.539,54.154 -72.68,54.154 c-36.963,0 -57.505,-23.103 -67.714,-40.429 l33.511,-20.138 c0,0 11.401,24.227 32.777,24.227 c21.377,0 29.927,-7.125 29.927,-23.514 c0,-19.951 -69.118,-27.076 -79.806,-61.279 s3.563,-82.656 53.441,-79.094 c31.175,2.227 48.71,17.535 57.478,28.703 L391.05,232.97 z " visibility="visible"></path><path style="fill:currentColor;" d="M235.714,183.805 c0,0 0,120.421 0,136.81 c0,16.388 -14.251,24.94 -28.502,22.089 c-14.251,-2.85 -20.664,-19.951 -20.664,-19.951 l-32.777,22.089 c0,0 7.125,32.777 52.016,35.628 c44.891,2.85 70.542,-24.227 70.542,-47.029 s0,-149.636 0,-149.636 L235.714,183.805 L235.714,183.805 z " visibility="visible"></path><circle style="fill:currentColor;" cx="90" cy="180" r="64"></circle></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+27 -22
View File
@@ -1,3 +1,4 @@
import * as React from 'react';
import { Types, Maybe, SortedCollection } from './common'; import { Types, Maybe, SortedCollection } from './common';
import { Column } from './components/List'; import { Column } from './components/List';
@@ -69,7 +70,7 @@ export class Node {
public lon: Maybe<Types.Longitude>; public lon: Maybe<Types.Longitude>;
public city: Maybe<Types.City>; public city: Maybe<Types.City>;
private readonly subscriptions = new Set<(node: Node) => void>(); private _changeRef = 0;
private readonly subscriptionsConsensus = new Set<(node: Node) => void>(); private readonly subscriptionsConsensus = new Set<(node: Node) => void>();
constructor( constructor(
@@ -188,29 +189,36 @@ export class Node {
} }
} }
public subscribe(handler: (node: Node) => void) { public get changeRef(): number {
this.subscriptions.add(handler); return this._changeRef;
}
public unsubscribe(handler: (node: Node) => void) {
this.subscriptions.delete(handler);
}
public subscribeConsensus(handler: (node: Node) => void) {
this.subscriptionsConsensus.add(handler);
}
public unsubscribeConsensus(handler: (node: Node) => void) {
this.subscriptionsConsensus.delete(handler);
} }
private trigger() { private trigger() {
for (const handler of this.subscriptions.values()) { this._changeRef += 1;
handler(this);
}
} }
} }
export function bindState(bind: React.Component, state: State): Update {
let isUpdating = false;
return (changes) => {
// Apply new changes to the state immediately
Object.assign(state, changes);
// Trigger React update on next animation frame only once
if (!isUpdating) {
isUpdating = true;
window.requestAnimationFrame(() => {
bind.forceUpdate();
isUpdating = false;
});
}
return state;
};
}
export namespace State { export namespace State {
export interface Settings { export interface Settings {
location: boolean; location: boolean;
@@ -262,11 +270,8 @@ export interface State {
} }
export type Update = <K extends keyof State>( export type Update = <K extends keyof State>(
changes: Pick<State, K> | null changes: Pick<State, K>
) => Readonly<State>; ) => Readonly<State>;
export type UpdateBound = <K extends keyof State>(
changes: Pick<State, K> | null
) => void;
export interface ChainData { export interface ChainData {
label: Types.ChainLabel; label: Types.ChainLabel;
+16
View File
@@ -95,3 +95,19 @@ export function setHashData(val: HashData) {
window.location.hash = `#${tab}/${encodeURIComponent(chain)}`; window.location.hash = `#${tab}/${encodeURIComponent(chain)}`;
} }
let root: null | SVGSVGElement = null;
export const W3SVG = 'http://www.w3.org/2000/svg';
// Get a root node where we all SVG symbols can be stored
// see: Icon.tsx
export function getSVGShadowRoot(): SVGSVGElement {
if (!root) {
root = document.createElementNS(W3SVG, 'svg');
root.setAttribute('style', 'display: none;');
document.body.appendChild(root);
}
return root;
}
+13 -38
View File
@@ -184,10 +184,6 @@
lodash "^4.17.13" lodash "^4.17.13"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@fnando/sparkline@maciejhirsz/sparkline":
version "0.3.10"
resolved "https://codeload.github.com/maciejhirsz/sparkline/tar.gz/2bdb002b171436be078a84f1e4e617a44ef1fb42"
"@istanbuljs/load-nyc-config@^1.0.0": "@istanbuljs/load-nyc-config@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b"
@@ -246,11 +242,6 @@
dependencies: dependencies:
any-observable "^0.3.0" any-observable "^0.3.0"
"@tanem/svg-injector@^1.2.0":
version "1.2.1"
resolved "https://registry.npmjs.org/@tanem/svg-injector/-/svg-injector-1.2.1.tgz#3120e90246d0eb3c4fc6c61586a6f028a3c658ae"
integrity sha512-mA5Q5ulPoGQ+e08Vts1R6xw2QU0BKEnMH/KcqoYoS7Gk6imvMTpyFPeu1g+NOZObSIoAzA3/kRzY8m96cEBA2A==
"@types/bn.js@^4.11.6": "@types/bn.js@^4.11.6":
version "4.11.6" version "4.11.6"
resolved "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" resolved "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c"
@@ -302,13 +293,6 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-svg@3.0.0":
version "3.0.0"
resolved "https://registry.npmjs.org/@types/react-svg/-/react-svg-3.0.0.tgz#ebbd0a095339ba20d9ba1d8fb3441eef9aeb5d11"
integrity sha512-9KO459enRlNfMWBAQEQraJJb7YeyIZ+U4+R2OVYmtl3WJlN7EKHKHpd9lSxCKzQa7BxQLgUwWxwTa3Nx7GtwEA==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^16.9.34": "@types/react@*", "@types/react@^16.9.34":
version "16.9.34" version "16.9.34"
resolved "https://registry.npmjs.org/@types/react/-/react-16.9.34.tgz#f7d5e331c468f53affed17a8a4d488cd44ea9349" resolved "https://registry.npmjs.org/@types/react/-/react-16.9.34.tgz#f7d5e331c468f53affed17a8a4d488cd44ea9349"
@@ -7745,15 +7729,14 @@ react-dev-utils@^5.0.2:
strip-ansi "3.0.1" strip-ansi "3.0.1"
text-table "0.2.0" text-table "0.2.0"
react-dom@^16.13.1: react-dom@^17.0.1:
version "16.13.1" version "17.0.1"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6"
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2" scheduler "^0.20.1"
scheduler "^0.19.1"
react-error-overlay@^4.0.1: react-error-overlay@^4.0.1:
version "4.0.1" version "4.0.1"
@@ -7820,21 +7803,13 @@ react-scripts-ts@3.1.0:
optionalDependencies: optionalDependencies:
fsevents "^1.1.3" fsevents "^1.1.3"
react-svg@4.1.1: react@^17.0.1:
version "4.1.1" version "17.0.1"
resolved "https://registry.npmjs.org/react-svg/-/react-svg-4.1.1.tgz#6151831e6f03e1ef5a090c61b12c30aa48185425" resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"
integrity sha512-PAFIRcXnOT2VXcP31DZJ+Xw11NBkvvDAfnAm5C2iVwVbngFUXPbqEKT0V4jquAOGE7ZDF4/Ok0xKInCkSKq1Iw== integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==
dependencies:
"@tanem/svg-injector" "^1.2.0"
react@^16.13.1:
version "16.13.1"
resolved "https://registry.npmjs.org/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2"
read-pkg-up@^1.0.1: read-pkg-up@^1.0.1:
version "1.0.1" version "1.0.1"
@@ -8346,10 +8321,10 @@ sax@^1.2.1, sax@^1.2.4, sax@~1.2.1:
resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.19.1: scheduler@^0.20.1:
version "0.19.1" version "0.20.1"
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c"
integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"