diff --git a/frontend/package.json b/frontend/package.json index 8a8ba79..71921dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,17 +16,15 @@ "clean": "rm -rf node_modules build .nyc env-config.js report*.json yarn-error.log" }, "dependencies": { - "@fnando/sparkline": "maciejhirsz/sparkline", "@polkadot/util-crypto": "^2.8.1", "@types/react-measure": "^2.0.6", "blakejs": "^1.1.0", "husky": "^4.2.5", "lint-staged": "^10.1.7", - "react": "^16.13.1", - "react-dom": "^16.13.1", + "react": "^17.0.1", + "react-dom": "^17.0.1", "react-measure": "^2.3.0", "react-scripts-ts": "3.1.0", - "react-svg": "4.1.1", "stable": "^0.1.8", "tslint": "^6.1.1" }, @@ -34,7 +32,6 @@ "@types/node": "^13.13.2", "@types/react": "^16.9.34", "@types/react-dom": "^16.9.6", - "@types/react-svg": "3.0.0", "@types/tape": "^4.13.0", "babel-preset-env": "^1.7.0", "babel-preset-react": "^6.24.1", diff --git a/frontend/src/AfgHandling.ts b/frontend/src/AfgHandling.ts index 7caba3a..277e015 100644 --- a/frontend/src/AfgHandling.ts +++ b/frontend/src/AfgHandling.ts @@ -1,17 +1,17 @@ import { Types } from './common'; -import { State, UpdateBound } from './state'; +import { State, Update } from './state'; import { ConsensusDetail } from './common/types'; // Number of blocks which are kept in memory const BLOCKS_LIMIT = 50; export class AfgHandling { - private updateState: UpdateBound; - private getState: () => Readonly; - - constructor(updateState: UpdateBound, getState: () => Readonly) { - this.updateState = updateState; - this.getState = getState; + constructor( + private readonly appUpdate: Update, + private readonly appState: Readonly + ) { + this.appUpdate = appUpdate; + this.appState = appState; } public receivedAuthoritySet( @@ -19,19 +19,19 @@ export class AfgHandling { authorities: Types.Authorities ) { if ( - this.getState().authoritySetId != null && - authoritySetId !== this.getState().authoritySetId + this.appState.authoritySetId != null && + authoritySetId !== this.appState.authoritySetId ) { // the visualization is restarted when we receive a new authority set - this.updateState({ + this.appUpdate({ authoritySetId, authorities, consensusInfo: [], displayConsensusLoadingScreen: false, }); - } else if (this.getState().authoritySetId == null) { + } else if (this.appState.authoritySetId == null) { // initial display - this.updateState({ + this.appUpdate({ authoritySetId, authorities, consensusInfo: [], @@ -46,7 +46,7 @@ export class AfgHandling { finalizedNumber: Types.BlockNumber, finalizedHash: Types.BlockHash ) { - const state = this.getState(); + const state = this.appState; if (finalizedNumber < state.best - BLOCKS_LIMIT) { return; } @@ -108,7 +108,7 @@ export class AfgHandling { this.backfill(state.consensusInfo, finalizedNumber, op, addr, addr); this.pruneBlocks(state.consensusInfo); - this.updateState({ consensusInfo: state.consensusInfo }); + this.appUpdate({ consensusInfo: state.consensusInfo }); } public receivedPre( @@ -117,7 +117,7 @@ export class AfgHandling { voter: Types.Address, what: string ) { - const state = this.getState(); + const state = this.appState; if (height < state.best - BLOCKS_LIMIT) { return; } @@ -165,11 +165,11 @@ export class AfgHandling { consensusInfo[index][1][addr][voter].ImplicitPointer = height; return true; }; - const consensusInfo = this.getState().consensusInfo; + const consensusInfo = this.appState.consensusInfo; this.backfill(consensusInfo, height, op, addr, voter); this.pruneBlocks(consensusInfo); - this.updateState({ consensusInfo }); + this.appUpdate({ consensusInfo }); } // Initializes the `ConsensusView` with empty objects. @@ -239,7 +239,7 @@ export class AfgHandling { } let firstBlockNumber = consensusInfo[consensusInfo.length - 1][0]; - const limit = this.getState().best - BLOCKS_LIMIT; + const limit = this.appState.best - BLOCKS_LIMIT; if (firstBlockNumber < limit) { firstBlockNumber = limit as Types.BlockNumber; } diff --git a/frontend/src/App.css b/frontend/src/App.css index 92fac85..251554a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,6 +2,7 @@ text-align: left; font-family: Roboto, Helvetica, Arial, sans-serif; font-size: 16px; + min-width: 1318px; } .App-no-telemetry { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ab7955..a4c653c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,15 +4,24 @@ import { AllChains, Chains, Chain, Ago, OfflineIndicator } from './components'; import { Row, Column } from './components/List'; import { Connection } from './Connection'; 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 stable from 'stable'; import './App.css'; -export default class App extends React.Component<{}, State> { - public state: State; +export default class App extends React.Component<{}, {}> { private chainsCache: ChainData[] = []; + // Custom state for finer control over updates + private readonly appState: Readonly; + private readonly appUpdate: Update; private readonly settings: PersistentObject; private readonly pins: PersistentSet; private readonly sortBy: Persistent>; @@ -52,28 +61,28 @@ export default class App extends React.Component<{}, State> { const selectedColumns = this.selectedColumns(settings); this.sortBy.set(null); - this.setState({ settings, selectedColumns, sortBy: null }); + this.appUpdate({ settings, selectedColumns, sortBy: null }); } ); this.pins = new PersistentSet('pinned_names', (pins) => { - const { nodes } = this.state; + const { nodes } = this.appState; nodes.mutEachAndSort((node) => node.setPinned(pins.has(node.name))); - this.setState({ nodes, pins }); + this.appUpdate({ nodes, pins }); }); this.sortBy = new Persistent>('sortBy', null, (sortBy) => { const compare = this.getComparator(sortBy); - this.state.nodes.setComparator(compare); - this.setState({ sortBy }); + this.appState.nodes.setComparator(compare); + this.appUpdate({ sortBy }); }); const { tab = '' } = getHashData(); - this.state = { + this.appUpdate = bindState(this, { status: 'offline', best: 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(), selectedColumns: this.selectedColumns(this.settings.raw()), 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 } public render() { - const { timeDiff, subscribed, status, tab } = this.state; + const { timeDiff, subscribed, status, tab } = this.appState; const chains = this.chains(); Ago.timeDiff = timeDiff; @@ -141,7 +150,8 @@ export default class App extends React.Component<{}, State> { connection={this.connection} /> { ); } - public componentWillMount() { + public componentDidMount() { window.addEventListener('keydown', this.onKeyPress); window.addEventListener('hashchange', this.onHashChange); } @@ -170,8 +180,8 @@ export default class App extends React.Component<{}, State> { event.preventDefault(); - const { subscribed } = this.state; - const chains = Array.from(this.state.chains.keys()); + const { subscribed } = this.appState; + const chains = Array.from(this.appState.chains.keys()); let index = 0; @@ -196,12 +206,12 @@ export default class App extends React.Component<{}, State> { }; private chains(): ChainData[] { - if (this.chainsCache.length === this.state.chains.size) { + if (this.chainsCache.length === this.appState.chains.size) { return this.chainsCache; } this.chainsCache = stable.inplace( - Array.from(this.state.chains.values()), + Array.from(this.appState.chains.values()), (a, b) => { const pinned = comparePinnedChains(a.label, b.label); @@ -223,7 +233,7 @@ export default class App extends React.Component<{}, State> { } private getComparator(sortBy: Maybe): Compare { - const columns = this.state.selectedColumns; + const columns = this.appState.selectedColumns; if (sortBy != null) { const [index, rev] = sortBy < 0 ? [~sortBy, -1] : [sortBy, 1]; diff --git a/frontend/src/Connection.ts b/frontend/src/Connection.ts index 8a790b2..a2ea557 100644 --- a/frontend/src/Connection.ts +++ b/frontend/src/Connection.ts @@ -1,18 +1,21 @@ import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from './common'; -import { - State, - Update, - Node, - UpdateBound, - ChainData, - PINNED_CHAINS, -} from './state'; +import { State, Update, Node, ChainData, PINNED_CHAINS } from './state'; import { PersistentSet } from './persist'; import { getHashData, setHashData } from './utils'; import { AfgHandling } from './AfgHandling'; import { VIS_AUTHORITIES_LIMIT } from './components/Consensus'; -import { Column } from './components/List'; 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_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes @@ -26,9 +29,10 @@ declare global { export class Connection { public static async create( pins: PersistentSet, - update: Update + appState: Readonly, + appUpdate: Update ): Promise { - 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'); @@ -102,27 +106,22 @@ export class Connection { private resubscribeTo: Maybe = getHashData().chain; // flag whether or not FE should subscribe to consensus updates on reconnect 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; - private readonly update: Update; - private readonly pins: PersistentSet; constructor( - socket: WebSocket, - update: Update, - pins: PersistentSet + private socket: WebSocket, + private readonly appState: Readonly, + private readonly appUpdate: Update, + private readonly pins: PersistentSet ) { - this.socket = socket; - this.update = update; - this.pins = pins; this.bindSocket(); } public subscribe(chain: Types.ChainLabel) { - if (this.state.subscribed != null && this.state.subscribed !== chain) { - this.state = this.update({ + if ( + this.appState.subscribed != null && + this.appState.subscribed !== chain + ) { + this.appUpdate({ tab: 'list', }); setHashData({ chain, tab: 'list' }); @@ -134,7 +133,7 @@ export class Connection { } public subscribeConsensus(chain: Types.ChainLabel) { - if (this.state.authorities.length <= VIS_AUTHORITIES_LIMIT) { + if (this.appState.authorities.length <= VIS_AUTHORITIES_LIMIT) { setHashData({ chain }); this.resubscribeSendFinality = true; this.socket.send(`send-finality:${chain}`); @@ -142,7 +141,7 @@ export class Connection { } public resetConsensus() { - this.state = this.update({ + this.appUpdate({ consensusInfo: new Array() as Types.ConsensusInfo, displayConsensusLoadingScreen: true, authorities: [] as Types.Address[], @@ -156,14 +155,10 @@ export class Connection { } public handleMessages = (messages: FeedMessage.Message[]) => { - const { nodes, chains, sortBy, selectedColumns } = this.state; + const { nodes, chains, sortBy, selectedColumns } = this.appState; const nodesStateRef = nodes.ref; - const updateState: UpdateBound = (state) => { - this.state = this.update(state); - }; - const getState = () => this.state; - const afg = new AfgHandling(updateState, getState); + const afg = new AfgHandling(this.appUpdate, this.appState); let sortByColumn: Maybe = null; @@ -176,13 +171,7 @@ export class Connection { switch (message.action) { case ACTIONS.FeedVersion: { if (message.payload !== VERSION) { - this.state = this.update({ status: 'upgrade-requested' }); - this.clean(); - - // Force reload from the server - setTimeout(() => window.location.reload(true), 3000); - - return; + return this.newVersion(); } break; @@ -193,7 +182,7 @@ export class Connection { nodes.mutEach((node) => node.newBestBlock()); - this.state = this.update({ best, blockTimestamp, blockAverage }); + this.appUpdate({ best, blockTimestamp, blockAverage }); break; } @@ -201,7 +190,7 @@ export class Connection { case ACTIONS.BestFinalized: { const [finalized /*, hash */] = message.payload; - this.state = this.update({ finalized }); + this.appUpdate({ finalized }); break; } @@ -257,7 +246,7 @@ export class Connection { nodes.mutAndMaybeSort( id, (node) => node.updateLocation([lat, lon, city]), - sortByColumn === Column.LOCATION + sortByColumn === LocationColumn ); break; @@ -277,8 +266,8 @@ export class Connection { nodes.mutAndMaybeSort( id, (node) => node.updateFinalized(height, hash), - sortByColumn === Column.FINALIZED || - sortByColumn === Column.FINALIZED_HASH + sortByColumn === FinalizedBlockColumn || + sortByColumn === FinalizedHashColumn ); break; @@ -290,7 +279,7 @@ export class Connection { nodes.mutAndMaybeSort( id, (node) => node.updateStats(nodeStats), - sortByColumn === Column.PEERS || sortByColumn === Column.TXS + sortByColumn === PeersColumn || sortByColumn === TxsColumn ); break; @@ -302,7 +291,7 @@ export class Connection { nodes.mutAndMaybeSort( id, (node) => node.updateHardware(nodeHardware), - sortByColumn === Column.UPLOAD || sortByColumn === Column.DOWNLOAD + sortByColumn === UploadColumn || sortByColumn === DownloadColumn ); break; @@ -314,14 +303,14 @@ export class Connection { nodes.mutAndMaybeSort( id, (node) => node.updateIO(nodeIO), - sortByColumn === Column.STATE_CACHE + sortByColumn === StateCacheColumn ); break; } case ACTIONS.TimeSync: { - this.state = this.update({ + this.appUpdate({ timeDiff: (timestamp() - message.payload) as Types.Milliseconds, }); @@ -338,7 +327,7 @@ export class Connection { chains.set(label, { label, nodeCount }); } - this.state = this.update({ chains }); + this.appUpdate({ chains }); break; } @@ -346,9 +335,9 @@ export class Connection { case ACTIONS.RemovedChain: { chains.delete(message.payload); - if (this.state.subscribed === message.payload) { + if (this.appState.subscribed === message.payload) { nodes.clear(); - this.state = this.update({ subscribed: null, nodes, chains }); + this.appUpdate({ subscribed: null, nodes, chains }); this.resetConsensus(); } @@ -358,16 +347,16 @@ export class Connection { case ACTIONS.SubscribedTo: { nodes.clear(); - this.state = this.update({ subscribed: message.payload, nodes }); + this.appUpdate({ subscribed: message.payload, nodes }); break; } case ACTIONS.UnsubscribedFrom: { - if (this.state.subscribed === message.payload) { + if (this.appState.subscribed === message.payload) { nodes.clear(); - this.state = this.update({ subscribed: null, nodes }); + this.appUpdate({ subscribed: null, nodes }); } break; @@ -416,12 +405,8 @@ export class Connection { } } - if (nodes.hasChangedSince(nodesStateRef) && !this.isUpdating) { - this.isUpdating = true; - window.requestAnimationFrame(() => { - this.update({ nodes }); - this.isUpdating = false; - }); + if (nodes.hasChangedSince(nodesStateRef)) { + this.appUpdate({ nodes }); } this.autoSubscribe(); @@ -430,19 +415,19 @@ export class Connection { private bindSocket() { this.ping(); - if (this.state) { - const { nodes } = this.state; + if (this.appState) { + const { nodes } = this.appState; nodes.clear(); } - this.state = this.update({ + this.appUpdate({ status: 'online', }); - if (this.state.subscribed) { - this.resubscribeTo = this.state.subscribed; - this.resubscribeSendFinality = this.state.sendFinality; - this.state = this.update({ subscribed: null, sendFinality: false }); + if (this.appState.subscribed) { + this.resubscribeTo = this.appState.subscribed; + this.resubscribeSendFinality = this.appState.sendFinality; + this.appUpdate({ subscribed: null, sendFinality: false }); } this.socket.addEventListener('message', this.handleFeedData); @@ -479,8 +464,14 @@ export class Connection { const latency = timestamp() - this.pingSent; 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() { @@ -493,18 +484,28 @@ export class Connection { } private handleFeedData = (event: MessageEvent) => { - const data = - typeof event.data === 'string' - ? ((event.data as any) as FeedMessage.Data) - : ((Connection.utf8decoder.decode( - event.data - ) as any) as FeedMessage.Data); + let data: FeedMessage.Data; + + if (typeof event.data === 'string') { + data = (event.data as any) as FeedMessage.Data; + } else { + 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)); }; private autoSubscribe() { - const { subscribed, chains } = this.state; + const { subscribed, chains } = this.appState; const { resubscribeTo, resubscribeSendFinality } = this; if (subscribed) { @@ -540,7 +541,7 @@ export class Connection { } private handleDisconnect = async () => { - this.state = this.update({ status: 'offline' }); + this.appUpdate({ status: 'offline' }); this.resetConsensus(); this.clean(); this.socket.close(); diff --git a/frontend/src/common/SortedCollection.ts b/frontend/src/common/SortedCollection.ts index e9ba377..7ddb0a2 100644 --- a/frontend/src/common/SortedCollection.ts +++ b/frontend/src/common/SortedCollection.ts @@ -181,7 +181,13 @@ export class SortedCollection { return; } + const index = sortedIndexOf(item, this.list, this.compare); + mutator(item); + + if (index >= this.focus.start && index < this.focus.end) { + this.changeRef += 1; + } } public mutAndSort(id: number, mutator: (item: Item) => void) { diff --git a/frontend/src/components/Ago.tsx b/frontend/src/components/Ago.tsx index b45f70a..4cadfd3 100644 --- a/frontend/src/components/Ago.tsx +++ b/frontend/src/components/Ago.tsx @@ -38,15 +38,29 @@ export class Ago extends React.Component { public state: Ago.State; + private agoStr: string; + constructor(props: Ago.Props) { super(props); this.state = { 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) => { this.setState({ now: (now - Ago.timeDiff) as Types.Timestamp, @@ -63,7 +77,13 @@ export class Ago extends React.Component { return -; } - const ago = Math.max(this.state.now - this.props.when, 0) / 1000; + return ( + {this.agoStr} + ); + } + + private stringify(when: number, now: number): string { + const ago = Math.max(now - when, 0) / 1000; let agoStr: string; @@ -83,8 +103,6 @@ export class Ago extends React.Component { agoStr += ' ago'; } - return ( - {agoStr} - ); + return agoStr; } } diff --git a/frontend/src/components/AllChains.tsx b/frontend/src/components/AllChains.tsx index c14ec38..0349d43 100644 --- a/frontend/src/components/AllChains.tsx +++ b/frontend/src/components/AllChains.tsx @@ -19,12 +19,12 @@ export class AllChains extends React.Component { const close = subscribed ? `#list/${subscribed}` : '#list'; return ( - + <>
{chains.map((chain) => this.renderChain(chain))}
-
+ ); } diff --git a/frontend/src/components/Chain/Chain.css b/frontend/src/components/Chain/Chain.css index fc2d243..067baeb 100644 --- a/frontend/src/components/Chain/Chain.css +++ b/frontend/src/components/Chain/Chain.css @@ -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 { position: absolute; left: 0; diff --git a/frontend/src/components/Chain/Chain.tsx b/frontend/src/components/Chain/Chain.tsx index 4cb8743..7052548 100644 --- a/frontend/src/components/Chain/Chain.tsx +++ b/frontend/src/components/Chain/Chain.tsx @@ -1,21 +1,12 @@ import * as React from 'react'; import { Connection } from '../../Connection'; import { Types, Maybe } from '../../common'; -import { State as AppState } from '../../state'; -import { formatNumber, secondsWithPrecision, getHashData } from '../../utils'; -import { Tab } from './'; +import { State as AppState, Update as AppUpdate } from '../../state'; +import { getHashData } from '../../utils'; +import { Header } from './'; import { Tile, Ago, List, Map, Settings, Consensus } from '../'; 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'; export namespace Chain { @@ -23,6 +14,7 @@ export namespace Chain { export interface Props { appState: Readonly; + appUpdate: AppUpdate; connection: Promise; settings: PersistentObject; pins: PersistentSet; @@ -64,56 +56,14 @@ export class Chain extends React.Component { return (
-
- - #{formatNumber(best)} - - - #{formatNumber(finalized)} - - - {blockAverage == null - ? '-' - : secondsWithPrecision(blockAverage / 1000)} - - - - -
- - - - -
-
+
{this.renderContent()}
@@ -128,14 +78,19 @@ export class Chain extends React.Component { return ; } - const { appState, connection, pins, sortBy } = this.props; + const { appState, appUpdate, connection, pins, sortBy } = this.props; if (display === 'consensus') { return ; } return display === 'list' ? ( - + ) : ( ); diff --git a/frontend/src/components/Chain/Header.css b/frontend/src/components/Chain/Header.css new file mode 100644 index 0000000..6f07ffb --- /dev/null +++ b/frontend/src/components/Chain/Header.css @@ -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; +} diff --git a/frontend/src/components/Chain/Header.tsx b/frontend/src/components/Chain/Header.tsx new file mode 100644 index 0000000..843ee62 --- /dev/null +++ b/frontend/src/components/Chain/Header.tsx @@ -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; + currentTab: Chain.Display; + setDisplay: (display: Chain.Display) => void; + } +} + +export class Header extends React.Component { + 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 ( +
+ + #{formatNumber(best)} + + + #{formatNumber(finalized)} + + + {blockAverage == null + ? '-' + : secondsWithPrecision(blockAverage / 1000)} + + + + +
+ + + + +
+
+ ); + } +} diff --git a/frontend/src/components/Chain/Tab.css b/frontend/src/components/Chain/Tab.css index 36c611e..73f0752 100644 --- a/frontend/src/components/Chain/Tab.css +++ b/frontend/src/components/Chain/Tab.css @@ -1,24 +1,23 @@ .Chain-Tab { display: inline-block; -} - -.Chain-Tab .Icon { margin-right: 5px; font-size: 24px; + line-height: 24px; + height: 24px; + width: 24px; padding: 6px; color: #555; cursor: pointer; padding: 10px; border-radius: 40px; - transition: background-color 0.15s linear; } -.Chain-Tab:hover .Icon { +.Chain-Tab:hover { background: #ccc; } -.Chain-Tab-on .Icon, -.Chain-Tab-on:hover .Icon { +.Chain-Tab-on, +.Chain-Tab-on:hover { background: #e6007a; color: #fff; } diff --git a/frontend/src/components/Chain/Tab.tsx b/frontend/src/components/Chain/Tab.tsx index 814794e..2a4a8fa 100644 --- a/frontend/src/components/Chain/Tab.tsx +++ b/frontend/src/components/Chain/Tab.tsx @@ -23,8 +23,8 @@ export class Tab extends React.Component { const className = highlight ? 'Chain-Tab-on Chain-Tab' : 'Chain-Tab'; return ( -
- +
+
); } diff --git a/frontend/src/components/Chain/index.ts b/frontend/src/components/Chain/index.ts index 03ecb91..0ef537f 100644 --- a/frontend/src/components/Chain/index.ts +++ b/frontend/src/components/Chain/index.ts @@ -1,2 +1,3 @@ export * from './Chain'; export * from './Tab'; +export * from './Header'; diff --git a/frontend/src/components/Chains.css b/frontend/src/components/Chains.css index ee06e7d..810461c 100644 --- a/frontend/src/components/Chains.css +++ b/frontend/src/components/Chains.css @@ -1,28 +1,25 @@ .Chains { - background: #b5aeae; + background: #e6007a; color: #000; padding: 0 76px 0 16px; height: 40px; - min-width: 1318px; + /* min-width is 1350 - 76 - 16 to account for padding */ + min-width: 1258px; position: relative; } .Chains-chain { + top: 4px; padding: 0 12px; - background: #b5aeae; - color: #444; + color: #fff; display: inline-block; - border-right: 1px solid rgba(255, 255, 255, 0.5); - height: 40px; - line-height: 40px; + margin-right: 4px; + height: 36; + line-height: 36px; cursor: pointer; font-size: 0.8em; - font-weight: bold; position: relative; -} - -.Chains-chain:first-child { - border-left: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 4px 4px 0 0; } .Chains-all-chains { @@ -49,27 +46,28 @@ margin: 0; height: 28px; width: 28px; - color: #3c3c3b; + color: #fff; } .Chains-node-count { + padding: 0 5px; display: inline-block; - padding: 0 0.5em 0.1em; - border-radius: 1em; - background: #8c8787; - color: #fff; - font-weight: normal; - text-shadow: rgba(0, 0, 0, 0.5) 0 1px 0; + height: 20px; + border-radius: 20px; + background: #fff; + color: #e6007a; font-size: 0.9em; - line-height: 1.4em; - margin: 0 -0.3em 0 0.3em; + line-height: 20px; + margin: 0 -0.5em 0 0.5em; } .Chains-chain-selected { background: #fff; - color: #000; + color: #393838; + font-weight: bold; } .Chains-chain-selected .Chains-node-count { - background: #e6007a; + background: #393838; + color: #fff; } diff --git a/frontend/src/components/Chains.tsx b/frontend/src/components/Chains.tsx index a3bea93..deacdab 100644 --- a/frontend/src/components/Chains.tsx +++ b/frontend/src/components/Chains.tsx @@ -5,7 +5,7 @@ import { Types, Maybe } from '../common'; import { ChainData } from '../state'; import githubIcon from '../icons/mark-github.svg'; -import listIcon from '../icons/three-bars.svg'; +import listIcon from '../icons/kebab-horizontal.svg'; import './Chains.css'; 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 { + private lastRender = performance.now(); + private clicked: Maybe; + + 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() { + this.lastRender = performance.now(); + const allChainsHref = this.props.subscribed ? `#all-chains/${this.props.subscribed}` : `#all-chains`; @@ -25,16 +46,21 @@ export class Chains extends React.Component { return (
); @@ -43,10 +69,11 @@ export class Chains extends React.Component { private renderChain(chain: ChainData): React.ReactNode { const { label, nodeCount } = chain; - const className = - label === this.props.subscribed - ? 'Chains-chain Chains-chain-selected' - : 'Chains-chain'; + let className = 'Chains-chain'; + + if (label === this.props.subscribed) { + className += ' Chains-chain-selected'; + } return ( { className={className} onClick={this.subscribe.bind(this, label)} > - {label}{' '} + {label} {nodeCount} @@ -63,6 +90,11 @@ export class Chains extends React.Component { } private async subscribe(chain: Types.ChainLabel) { + if (chain === this.clicked) { + return; + } + this.clicked = chain; + const connection = await this.props.connection; connection.subscribe(chain); diff --git a/frontend/src/components/Consensus/ConsensusBlock.tsx b/frontend/src/components/Consensus/ConsensusBlock.tsx index a42e2da..45c5070 100644 --- a/frontend/src/components/Consensus/ConsensusBlock.tsx +++ b/frontend/src/components/Consensus/ConsensusBlock.tsx @@ -195,9 +195,9 @@ export class ConsensusBlock extends React.Component { ]; finalizedInfo = matrice.ImplicitFinalized ? ( - + ) : ( - + ); finalizedHash = matrice.FinalizedHash ? ( @@ -301,25 +301,17 @@ export class ConsensusBlock extends React.Component { let statPrecommit; if (implicitPrevote) { - statPrevote = ( - - ); + statPrevote = ; } if (implicitPrecommit) { - statPrecommit = ( - - ); + statPrecommit = ; } if (prevote) { - statPrevote = ( - - ); + statPrevote = ; } if (precommit) { - statPrecommit = ( - - ); + statPrecommit = ; } return ( @@ -329,7 +321,7 @@ export class ConsensusBlock extends React.Component { ); } else { - return ; + return ; } } } diff --git a/frontend/src/components/Filter.tsx b/frontend/src/components/Filter.tsx index 9f66213..37e6799 100644 --- a/frontend/src/components/Filter.tsx +++ b/frontend/src/components/Filter.tsx @@ -26,7 +26,7 @@ export class Filter extends React.Component { private filterInput: HTMLInputElement; - public componentWillMount() { + public componentDidMount() { window.addEventListener('keyup', this.onWindowKeyUp); } diff --git a/frontend/src/components/Icon.css b/frontend/src/components/Icon.css index 768269c..a1402a8 100644 --- a/frontend/src/components/Icon.css +++ b/frontend/src/components/Icon.css @@ -8,7 +8,7 @@ display: inline-block; } -.Icon svg { - width: auto; +.Icon svg, .Icon-symbol-root symbol { + width: 1em; height: 1em; } diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index cde4054..a1371f2 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -1,36 +1,75 @@ import * as React from 'react'; -import ReactSVG from 'react-svg'; import './Icon.css'; +import { getSVGShadowRoot, W3SVG } from '../utils'; -export interface Props { - src: string; - alt?: string; - className?: string; - onClick?: () => void; +export namespace Icon { + export interface Props { + src: string; + className?: string; + onClick?: () => void; + } } -export class Icon extends React.Component<{}, Props> { - public props: Props; +const symbols = new Map(); - 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 { + public props: Icon.Props; + + public shouldComponentUpdate(nextProps: Icon.Props) { return ( this.props.src !== nextProps.src || - this.props.alt !== nextProps.alt || this.props.className !== nextProps.className ); } 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 ( - + + + ); } } diff --git a/frontend/src/components/List/Column.tsx b/frontend/src/components/List/Column.tsx deleted file mode 100644 index 90b38a9..0000000 --- a/frontend/src/components/List/Column.tsx +++ /dev/null @@ -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 | 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 }) => , - }; - - export const VALIDATOR: Column = { - label: 'Validator', - icon: nodeValidatorIcon, - width: 16, - setting: 'validator', - sortBy: ({ validator }) => validator || '', - render: ({ validator }) => { - return validator ? ( - - - - - - ) : ( - '-' - ); - }, - }; - - export const LOCATION: Column = { - label: 'Location', - icon: nodeLocationIcon, - width: 140, - setting: 'location', - sortBy: ({ city }) => city || '', - render: ({ city }) => - 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 ( - - {semver} - - ); - }, - }; - - export const NETWORK_ID: Column = { - label: 'Network ID', - icon: networkIdIcon, - width: 90, - setting: 'networkId', - sortBy: ({ networkId }) => networkId || '', - render: ({ networkId }) => - 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 ( - - ); - }, - }; - - 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 ( - - ); - }, - }; - - 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 ( - - ); - }, - }; - - 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 }) => , - }; - - 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 }) => ( - - ), - }; - - 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 }) => , - }; - - export const UPTIME: Column = { - label: 'Node Uptime', - icon: uptimeIcon, - width: 58, - setting: 'uptime', - sortBy: ({ connectedAt }) => connectedAt || 0, - render: ({ connectedAt }) => , - }; - - 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 ( - - - - ); - }, - }; -} - -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): 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): 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): 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): string { - const ago = stamp ? ` (${formatStamp(stamp)})` : ''; - const fractionDigits = cpu > 100 ? 0 : cpu > 10 ? 1 : cpu > 1 ? 2 : 3; - - return `${cpu.toFixed(fractionDigits)}%${ago}`; -} diff --git a/frontend/src/components/List/Column/BlockHashColumn.tsx b/frontend/src/components/List/Column/BlockHashColumn.tsx new file mode 100644 index 0000000..2b5b193 --- /dev/null +++ b/frontend/src/components/List/Column/BlockHashColumn.tsx @@ -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 { + 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; + private copy: Maybe; + + public shouldComponentUpdate(nextProps: Column.Props) { + return this.data !== nextProps.node.hash; + } + + render() { + const { hash } = this.props.node; + + this.data = hash; + + return ( + + + + + ); + } + + private onCopy = (copy: Tooltip.CopyCallback) => { + this.copy = copy; + }; + + private onClick = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (this.copy != null) { + this.copy(); + } + }; +} diff --git a/frontend/src/components/List/Column/BlockNumberColumn.tsx b/frontend/src/components/List/Column/BlockNumberColumn.tsx new file mode 100644 index 0000000..043a076 --- /dev/null +++ b/frontend/src/components/List/Column/BlockNumberColumn.tsx @@ -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 { + 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 {`#${formatNumber(height)}`}; + } +} diff --git a/frontend/src/components/List/Column/BlockPropagationColumn.tsx b/frontend/src/components/List/Column/BlockPropagationColumn.tsx new file mode 100644 index 0000000..49de8cc --- /dev/null +++ b/frontend/src/components/List/Column/BlockPropagationColumn.tsx @@ -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 { + 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; + + 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 {print}; + } +} diff --git a/frontend/src/components/List/Column/BlockTimeColumn.tsx b/frontend/src/components/List/Column/BlockTimeColumn.tsx new file mode 100644 index 0000000..8a2353e --- /dev/null +++ b/frontend/src/components/List/Column/BlockTimeColumn.tsx @@ -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 { + 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 ( + {`${secondsWithPrecision(blockTime / 1000)}`} + ); + } +} diff --git a/frontend/src/components/List/Column/Column.css b/frontend/src/components/List/Column/Column.css new file mode 100644 index 0000000..a7ff76f --- /dev/null +++ b/frontend/src/components/List/Column/Column.css @@ -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; +} + diff --git a/frontend/src/components/List/Column/Column.tsx b/frontend/src/components/List/Column/Column.tsx new file mode 100644 index 0000000..b84d91d --- /dev/null +++ b/frontend/src/components/List/Column/Column.tsx @@ -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 + ): 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 + ): 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`; +} diff --git a/frontend/src/components/List/Column/DownloadColumn.tsx b/frontend/src/components/List/Column/DownloadColumn.tsx new file mode 100644 index 0000000..a0ce5b9 --- /dev/null +++ b/frontend/src/components/List/Column/DownloadColumn.tsx @@ -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 { + 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 = []; + + 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 -; + } + + return ( + + + + ); + } +} diff --git a/frontend/src/components/List/Column/FinalizedBlockColumn.tsx b/frontend/src/components/List/Column/FinalizedBlockColumn.tsx new file mode 100644 index 0000000..ba6db5b --- /dev/null +++ b/frontend/src/components/List/Column/FinalizedBlockColumn.tsx @@ -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 { + 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 {`#${formatNumber(finalized)}`}; + } +} diff --git a/frontend/src/components/List/Column/FinalizedHashColumn.tsx b/frontend/src/components/List/Column/FinalizedHashColumn.tsx new file mode 100644 index 0000000..16dd245 --- /dev/null +++ b/frontend/src/components/List/Column/FinalizedHashColumn.tsx @@ -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 { + 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; + private copy: Maybe; + + public shouldComponentUpdate(nextProps: Column.Props) { + return this.data !== nextProps.node.finalizedHash; + } + + render() { + const { finalizedHash } = this.props.node; + + this.data = finalizedHash; + + return ( + + + + + ); + } + + private onCopy = (copy: Tooltip.CopyCallback) => { + this.copy = copy; + }; + + private onClick = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (this.copy != null) { + this.copy(); + } + }; +} diff --git a/frontend/src/components/List/Column/ImplementationColumn.tsx b/frontend/src/components/List/Column/ImplementationColumn.tsx new file mode 100644 index 0000000..9a7db39 --- /dev/null +++ b/frontend/src/components/List/Column/ImplementationColumn.tsx @@ -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 { + 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 ( + + + {semver} + + ); + } +} diff --git a/frontend/src/components/List/Column/LastBlockColumn.tsx b/frontend/src/components/List/Column/LastBlockColumn.tsx new file mode 100644 index 0000000..37ffe15 --- /dev/null +++ b/frontend/src/components/List/Column/LastBlockColumn.tsx @@ -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 { + 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 ( + + + + ); + } +} diff --git a/frontend/src/components/List/Column/LocationColumn.tsx b/frontend/src/components/List/Column/LocationColumn.tsx new file mode 100644 index 0000000..5ba77e1 --- /dev/null +++ b/frontend/src/components/List/Column/LocationColumn.tsx @@ -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 { + 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; + + public shouldComponentUpdate(nextProps: Column.Props) { + return this.data !== nextProps.node.city; + } + + render() { + const { city } = this.props.node; + + this.data = city; + + if (!city) { + return -; + } + + return ( + + + + + ); + } +} diff --git a/frontend/src/components/List/Column/NameColumn.tsx b/frontend/src/components/List/Column/NameColumn.tsx new file mode 100644 index 0000000..dce4689 --- /dev/null +++ b/frontend/src/components/List/Column/NameColumn.tsx @@ -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 { + 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 ( + + + + + ); + } +} diff --git a/frontend/src/components/List/Column/NetworkIdColumn.tsx b/frontend/src/components/List/Column/NetworkIdColumn.tsx new file mode 100644 index 0000000..f339093 --- /dev/null +++ b/frontend/src/components/List/Column/NetworkIdColumn.tsx @@ -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 { + 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; + + public shouldComponentUpdate(nextProps: Column.Props) { + return this.data !== nextProps.node.networkId; + } + + render() { + const { networkId } = this.props.node; + + this.data = networkId; + + if (!networkId) { + return -; + } + + return ( + + + + + ); + } +} diff --git a/frontend/src/components/List/Column/NetworkStateColumn.tsx b/frontend/src/components/List/Column/NetworkStateColumn.tsx new file mode 100644 index 0000000..6f71397 --- /dev/null +++ b/frontend/src/components/List/Column/NetworkStateColumn.tsx @@ -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 { + 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 -; + } + + const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`; + + return ( + + + + + + ); + } +} diff --git a/frontend/src/components/List/Column/PeersColumn.tsx b/frontend/src/components/List/Column/PeersColumn.tsx new file mode 100644 index 0000000..1ec83f1 --- /dev/null +++ b/frontend/src/components/List/Column/PeersColumn.tsx @@ -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 { + 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 {peers}; + } +} diff --git a/frontend/src/components/List/Column/StateCacheColumn.tsx b/frontend/src/components/List/Column/StateCacheColumn.tsx new file mode 100644 index 0000000..a460a8b --- /dev/null +++ b/frontend/src/components/List/Column/StateCacheColumn.tsx @@ -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 { + 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 = []; + + 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 -; + } + + return ( + + + + ); + } +} diff --git a/frontend/src/components/List/Column/TxsColumn.tsx b/frontend/src/components/List/Column/TxsColumn.tsx new file mode 100644 index 0000000..e8d1f0e --- /dev/null +++ b/frontend/src/components/List/Column/TxsColumn.tsx @@ -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 { + 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 {txs}; + } +} diff --git a/frontend/src/components/List/Column/UploadColumn.tsx b/frontend/src/components/List/Column/UploadColumn.tsx new file mode 100644 index 0000000..f532011 --- /dev/null +++ b/frontend/src/components/List/Column/UploadColumn.tsx @@ -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 { + 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 = []; + + 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 -; + } + + return ( + + + + ); + } +} diff --git a/frontend/src/components/List/Column/UptimeColumn.tsx b/frontend/src/components/List/Column/UptimeColumn.tsx new file mode 100644 index 0000000..d596dca --- /dev/null +++ b/frontend/src/components/List/Column/UptimeColumn.tsx @@ -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 { + 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 ( + + + + ); + } +} diff --git a/frontend/src/components/List/Column/ValidatorColumn.tsx b/frontend/src/components/List/Column/ValidatorColumn.tsx new file mode 100644 index 0000000..e1ce963 --- /dev/null +++ b/frontend/src/components/List/Column/ValidatorColumn.tsx @@ -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 { + 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; + private copy: Maybe; + + public shouldComponentUpdate(nextProps: Column.Props) { + return this.data !== nextProps.node.validator; + } + + render() { + const { validator } = this.props.node; + + this.data = validator; + + if (!validator) { + return -; + } + + return ( + + + + + ); + } + + private onCopy = (copy: Tooltip.CopyCallback) => { + this.copy = copy; + }; + + private onClick = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (this.copy != null) { + this.copy(); + } + }; +} diff --git a/frontend/src/components/List/Column/index.ts b/frontend/src/components/List/Column/index.ts new file mode 100644 index 0000000..87cecdf --- /dev/null +++ b/frontend/src/components/List/Column/index.ts @@ -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'; diff --git a/frontend/src/components/List/List.css b/frontend/src/components/List/List.css index 915a279..d34b6e1 100644 --- a/frontend/src/components/List/List.css +++ b/frontend/src/components/List/List.css @@ -11,15 +11,13 @@ font-weight: 300; } -.List table { +.List-padding { + padding: 0; + margin: 0; +} + +.List--table { width: 100%; border-spacing: 0; -} - -.List thead { - background: #393838; -} - -.List tbody { font-family: monospace, sans-serif; } diff --git a/frontend/src/components/List/List.tsx b/frontend/src/components/List/List.tsx index 50a0b27..7c4b5c2 100644 --- a/frontend/src/components/List/List.tsx +++ b/frontend/src/components/List/List.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { Types, Maybe } from '../../common'; import { Filter } from '../'; -import { State as AppState, Node } from '../../state'; -import { Row } from './'; +import { State as AppState, Update as AppUpdate, Node } from '../../state'; +import { Row, THead } from './'; import { Persistent, PersistentSet } from '../../persist'; import { viewport } from '../../utils'; @@ -16,6 +16,7 @@ import './List.css'; export namespace List { export interface Props { appState: Readonly; + appUpdate: AppUpdate; pins: PersistentSet; sortBy: Persistent>; } @@ -23,20 +24,24 @@ export namespace List { export interface State { filter: Maybe<(node: Node) => boolean>; 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 { public state = { filter: null, viewportHeight: viewport().height, - listStart: 0, - listEnd: 0, }; + private listStart = 0; + private listEnd = 0; private relativeTop = -1; + private nextKey: Key = 0; + private previousKeys = new Map(); public componentDidMount() { this.onScroll(); @@ -53,7 +58,7 @@ export class List extends React.Component { public render() { const { pins, sortBy, appState } = this.props; const { selectedColumns } = appState; - const { filter, listStart, listEnd } = this.state; + const { filter } = this.state; let nodes = appState.nodes.sorted(); @@ -76,23 +81,26 @@ export class List extends React.Component { // to rendering view, so we put the whole list in focus appState.nodes.setFocus(0, nodes.length); } else { - appState.nodes.setFocus(listStart, listEnd); + appState.nodes.setFocus(this.listStart, this.listEnd); } 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 ( - + <>
- - - - {nodes.map((node) => ( +
+ + + + {nodes.map((node, i) => ( {
-
+ ); } + // 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): Array { + // First we find all keys for `Node`s which didn't change from + // last render. + const keptKeys: Array> = 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, 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 = () => { const relativeTop = divisibleBy( window.scrollY - (HEADER + TR_HEIGHT), @@ -125,8 +186,10 @@ export class List extends React.Component { const listStart = Math.max(((top / TR_HEIGHT) | 0) - ROW_MARGIN, 0); const listEnd = listStart + ROW_MARGIN * 2 + Math.ceil(height / TR_HEIGHT); - if (listStart !== this.state.listStart || listEnd !== this.state.listEnd) { - this.setState({ listStart, listEnd }); + if (listStart !== this.listStart || listEnd !== this.listEnd) { + this.listStart = listStart; + this.listEnd = listEnd; + this.props.appUpdate({}); } }; diff --git a/frontend/src/components/List/Row.css b/frontend/src/components/List/Row.css index 8995eb6..af5c98d 100644 --- a/frontend/src/components/List/Row.css +++ b/frontend/src/components/List/Row.css @@ -3,56 +3,6 @@ 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 { color: #fff; } @@ -72,20 +22,9 @@ } .Row-stale { - font-style: italic; + color: #555; } .Row:hover { background-color: #1e1e1e; } - -.Row-validator { - display: block; - width: 16px; - height: 16px; - cursor: pointer; -} - -.Row-validator:hover { - transform: scale(2); -} diff --git a/frontend/src/components/List/Row.tsx b/frontend/src/components/List/Row.tsx index 73cb418..b349fac 100644 --- a/frontend/src/components/List/Row.tsx +++ b/frontend/src/components/List/Row.tsx @@ -2,7 +2,28 @@ import * as React from 'react'; import { Types, Maybe } from '../../common'; import { Node } from '../../state'; 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'; @@ -25,75 +46,41 @@ interface HeaderProps { export class Row extends React.Component { public static readonly columns: Column[] = [ - Column.NAME, - Column.VALIDATOR, - Column.LOCATION, - Column.IMPLEMENTATION, - Column.NETWORK_ID, - Column.PEERS, - Column.TXS, - Column.UPLOAD, - Column.DOWNLOAD, - Column.STATE_CACHE, - Column.BLOCK_NUMBER, - Column.BLOCK_HASH, - Column.FINALIZED, - Column.FINALIZED_HASH, - Column.BLOCK_TIME, - Column.BLOCK_PROPAGATION, - Column.BLOCK_LAST_TIME, - Column.UPTIME, - Column.NETWORK_STATE, + NameColumn, + ValidatorColumn, + LocationColumn, + ImplementationColumn, + NetworkIdColumn, + PeersColumn, + TxsColumn, + UploadColumn, + DownloadColumn, + StateCacheColumn, + BlockNumberColumn, + BlockHashColumn, + FinalizedBlockColumn, + FinalizedHashColumn, + BlockTimeColumn, + BlockPropagationColumn, + LastBlockColumn, + UptimeColumn, + NetworkStateColumn, ]; - public static HEADER = (props: HeaderProps) => { - const { columns, sortBy } = props; - const last = columns.length - 1; + private renderedChangeRef = 0; - return ( - - - {columns.map((col, index) => ( - - ))} - - - ); - }; - - 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 { + public shouldComponentUpdate(nextProps: Row.Props): boolean { return ( this.props.node.id !== nextProps.node.id || - this.state.update !== nextState.update + this.renderedChangeRef !== nextProps.node.changeRef ); } public render() { const { node, columns } = this.props; + this.renderedChangeRef = node.changeRef; + let className = 'Row'; if (node.propagationTime != null) { @@ -110,9 +97,9 @@ export class Row extends React.Component { return ( - {columns.map(({ render }, index) => ( - {render(node)} - ))} + {columns.map((col, index) => + React.createElement(col, { node, key: index }) + )} ); } @@ -126,8 +113,4 @@ export class Row extends React.Component { pins.add(node.name); } }; - - private onUpdate = () => { - this.setState({ update: this.state.update + 1 }); - }; } diff --git a/frontend/src/components/List/THead.css b/frontend/src/components/List/THead.css new file mode 100644 index 0000000..7e131cd --- /dev/null +++ b/frontend/src/components/List/THead.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/components/List/THead.tsx b/frontend/src/components/List/THead.tsx new file mode 100644 index 0000000..edf29af --- /dev/null +++ b/frontend/src/components/List/THead.tsx @@ -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>; + } +} + +export class THead extends React.Component { + private sortBy: Maybe; + + 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 ( + + + {columns.map((col, index) => ( + + ))} + + + ); + } +} diff --git a/frontend/src/components/List/HeaderCell.tsx b/frontend/src/components/List/THeadCell.tsx similarity index 81% rename from frontend/src/components/List/HeaderCell.tsx rename to frontend/src/components/List/THeadCell.tsx index 9e66d5b..73d5cd4 100644 --- a/frontend/src/components/List/HeaderCell.tsx +++ b/frontend/src/components/List/THeadCell.tsx @@ -7,7 +7,7 @@ import { Persistent } from '../../persist'; import sortAscIcon from '../../icons/triangle-up.svg'; import sortDescIcon from '../../icons/triangle-down.svg'; -export namespace HeaderCell { +export namespace THeadCell { export interface Props { column: Column; index: number; @@ -16,7 +16,7 @@ export namespace HeaderCell { } } -export class HeaderCell extends React.Component { +export class THeadCell extends React.Component { public render() { const { column, index, last } = this.props; const { icon, width, label } = column; @@ -25,10 +25,10 @@ export class HeaderCell extends React.Component { const sortBy = this.props.sortBy.get(); const className = column.sortBy == null - ? '' + ? 'THeadCell' : sortBy === index || sortBy === ~index - ? 'HeaderCell-sorted' - : 'HeaderCell-sortable'; + ? 'THeadCell THeadCell-sorted' + : 'THeadCell THeadCell-sortable'; const i = sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon; @@ -38,9 +38,10 @@ export class HeaderCell extends React.Component { style={width ? { width } : undefined} onClick={this.toggleSort} > - + + - + ); } diff --git a/frontend/src/components/List/Truncate.tsx b/frontend/src/components/List/Truncate.tsx deleted file mode 100644 index daa9f2d..0000000 --- a/frontend/src/components/List/Truncate.tsx +++ /dev/null @@ -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 { - public render() { - const { text, position, copy } = this.props; - - if (!text) { - return '-'; - } - - return ( - -
{text}
-
- ); - } - - public shouldComponentUpdate(nextProps: Truncate.Props): boolean { - return ( - this.props.text !== nextProps.text || - this.props.position !== nextProps.position - ); - } -} diff --git a/frontend/src/components/List/index.ts b/frontend/src/components/List/index.ts index 5c0aa0c..b187e98 100644 --- a/frontend/src/components/List/index.ts +++ b/frontend/src/components/List/index.ts @@ -1,5 +1,5 @@ export * from './Column'; export * from './List'; -export * from './Truncate'; export * from './Row'; -export * from './HeaderCell'; +export * from './THeadCell'; +export * from './THead'; diff --git a/frontend/src/components/Map/Location.tsx b/frontend/src/components/Map/Location.tsx index 546f4c0..cb1b3a8 100644 --- a/frontend/src/components/Map/Location.tsx +++ b/frontend/src/components/Map/Location.tsx @@ -97,7 +97,7 @@ export class Location extends React.Component { validatorRow = ( - + {trimHash(validator, 30)} @@ -113,53 +113,53 @@ export class Location extends React.Component { - {validatorRow} - - - - - - -
- + + {name}
- + + {implementation} v{version}
- + + {city}
- + + #{formatNumber(height)}
- + + {trimHash(hash, 20)}
- + + {secondsWithPrecision(blockTime / 1000)} - + + {propagationTime == null ? '∞' : milliOrSecond(propagationTime)} - + + diff --git a/frontend/src/components/Map/Map.tsx b/frontend/src/components/Map/Map.tsx index fa8617e..176605f 100644 --- a/frontend/src/components/Map/Map.tsx +++ b/frontend/src/components/Map/Map.tsx @@ -34,7 +34,7 @@ export class Map extends React.Component { left: 0, }; - public componentWillMount() { + public componentDidMount() { this.onResize(); window.addEventListener('resize', this.onResize); diff --git a/frontend/src/components/OfflineIndicator.tsx b/frontend/src/components/OfflineIndicator.tsx index 802230d..1f9431f 100644 --- a/frontend/src/components/OfflineIndicator.tsx +++ b/frontend/src/components/OfflineIndicator.tsx @@ -19,14 +19,17 @@ export function OfflineIndicator( return null; case 'offline': return ( -
- +
+
); case 'upgrade-requested': return ( -
- +
+
); } diff --git a/frontend/src/components/PolkadotIcon.tsx b/frontend/src/components/PolkadotIcon.tsx index 5513cc5..8dab9b8 100644 --- a/frontend/src/components/PolkadotIcon.tsx +++ b/frontend/src/components/PolkadotIcon.tsx @@ -8,6 +8,7 @@ // https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx import * as React from 'react'; import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto'; +import { getSVGShadowRoot, W3SVG } from '../utils'; interface Circle { cx: number; @@ -172,20 +173,19 @@ function getColors(address: string): string[] { /** * @description Generate a array of the circles that make up an indenticon */ -export default function generate( - address: string, - isSixPoint = false -): Circle[] { +function generate(address: string, isSixPoint = false): Circle[] { const colors = getColors(address); return [OUTER_CIRCLE].concat( getCircleXY(isSixPoint).map( - ([cx, cy], index): Circle => ({ - cx, - cy, - r: Z, - fill: colors[index], - }) + ([cx, cy], index): Circle => { + return { + cx, + cy, + r: Z, + fill: colors[index], + }; + } ) ); } @@ -194,24 +194,54 @@ export namespace PolkadotIcon { export interface Props { account: string; size: number; + className?: string; + } +} + +const rendered = new Set(); + +// 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 { - public render(): React.ReactNode { - const { account, size } = this.props; - + public shouldComponentUpdate(nextProps: PolkadotIcon.Props) { return ( - - {generate(account, false).map(this.renderCircle)} - + this.props.account !== nextProps.account || + this.props.size !== nextProps.size ); } - private renderCircle = ( - { cx, cy, r, fill }: Circle, - key: number - ): React.ReactNode => { - return ; - }; + public render(): React.ReactNode { + const { account, size, className } = this.props; + renderShadowIcon(account); + + return ( + + + + ); + } } diff --git a/frontend/src/components/Settings/Setting.tsx b/frontend/src/components/Settings/Setting.tsx index 013ce74..745df0c 100644 --- a/frontend/src/components/Settings/Setting.tsx +++ b/frontend/src/components/Settings/Setting.tsx @@ -23,7 +23,7 @@ export class Setting extends React.Component { return (
- + {label} diff --git a/frontend/src/components/Sparkline.css b/frontend/src/components/Sparkline.css index cbaaae8..71f2e94 100644 --- a/frontend/src/components/Sparkline.css +++ b/frontend/src/components/Sparkline.css @@ -4,3 +4,15 @@ stroke: currentcolor; margin: 0 -1px -3px -1px; } + +.Sparkline path { + pointer-events: none; +} + +.Sparkline .Sparkline-cursor { + display: none; +} + +.Sparkline:hover .Sparkline-cursor { + display: initial; +} diff --git a/frontend/src/components/Sparkline.tsx b/frontend/src/components/Sparkline.tsx index 17b9502..526fe8f 100644 --- a/frontend/src/components/Sparkline.tsx +++ b/frontend/src/components/Sparkline.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Types, Maybe } from '../common'; -import sparkline from '@fnando/sparkline'; import { Tooltip } from './'; import './Sparkline.css'; @@ -18,60 +17,64 @@ export namespace Sparkline { } export class Sparkline extends React.Component { - private el: SVGSVGElement; + private cursor: SVGPathElement; 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 { - 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 || width !== nextProps.width || height !== nextProps.height || 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 ( - - - ); } - private onRef = (el: SVGSVGElement) => { - this.el = el; + public render() { + 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 ( + <> + + + + + + + + ); + } + + private onRef = (cursor: SVGPathElement) => { + this.cursor = cursor; }; private onTooltipInit = (update: Tooltip.UpdateCallback) => { @@ -79,13 +82,22 @@ export class Sparkline extends React.Component { }; private onMouseMove = ( - _event: MouseEvent, - data: { value: number; index: number } + event: React.MouseEvent ) => { - 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 - ? format(data.value, stamps ? stamps[data.index] : null) - : `${data.value}`; + ? format(values[cur], stamps ? stamps[cur] : null) + : `${values[cur]}`; this.update(str); }; + + private onMouseLeave = () => { + this.cursor.removeAttribute('d'); + }; } diff --git a/frontend/src/components/Tile.tsx b/frontend/src/components/Tile.tsx index 5f35994..9225289 100644 --- a/frontend/src/components/Tile.tsx +++ b/frontend/src/components/Tile.tsx @@ -13,7 +13,7 @@ export namespace Tile { export function Tile(props: Tile.Props) { return (
- + {props.title} {props.children}
diff --git a/frontend/src/components/Tooltip.css b/frontend/src/components/Tooltip.css index 8a0a354..a463999 100644 --- a/frontend/src/components/Tooltip.css +++ b/frontend/src/components/Tooltip.css @@ -74,17 +74,7 @@ right: 0; } -.Tooltip-container:hover .Tooltip { +:hover > .Tooltip { display: block; - animation: show 0.15s forwards; } -@keyframes show { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 2da1612..e77a493 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; +import { Maybe } from '../common'; import './Tooltip.css'; export namespace Tooltip { export interface Props { text: string; - copy?: boolean; - inline?: boolean; + copy?: (cb: CopyCallback) => void; className?: string; position?: 'left' | 'right' | 'center'; onInit?: (update: UpdateCallback) => void; @@ -17,6 +17,7 @@ export namespace Tooltip { } export type UpdateCallback = (text: string) => void; + export type CopyCallback = Maybe<() => void>; } function copyToClipboard(text: string) { @@ -32,33 +33,42 @@ export class Tooltip extends React.Component { public state = { copied: false }; private el: HTMLDivElement; - private timer: NodeJS.Timer; + private timer: NodeJS.Timer | null = null; public componentDidMount() { if (this.props.onInit) { this.props.onInit(this.update); } + if (this.props.copy) { + this.props.copy(this.onClick); + } } 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() { - const { text, inline, className, position } = this.props; + const { text, className, position } = this.props; const { copied } = this.state; - let containerClass = 'Tooltip-container'; let tooltipClass = 'Tooltip'; - if (className) { - containerClass += ' ' + className; - } - - if (inline) { - containerClass += ' Tooltip-container-inline'; - } - if (position && position !== 'center') { tooltipClass += ` Tooltip-${position}`; } @@ -68,11 +78,8 @@ export class Tooltip extends React.Component { } return ( -
-
- {copied ? 'Copied to clipboard!' : text} -
- {this.props.children} +
+ {copied ? 'Copied to clipboard!' : text}
); } @@ -85,16 +92,12 @@ export class Tooltip extends React.Component { this.el.textContent = text; }; - private onClick = (event: React.MouseEvent) => { - if (this.props.copy !== true) { - return; - } - + private onClick = () => { copyToClipboard(this.props.text); - event.stopPropagation(); - - clearTimeout(this.timer); + if (this.timer) { + clearTimeout(this.timer); + } this.setState({ copied: true }); this.timer = setTimeout(this.restore, 2000); @@ -102,5 +105,6 @@ export class Tooltip extends React.Component { private restore = () => { this.setState({ copied: false }); + this.timer = null; }; } diff --git a/frontend/src/components/Truncate.tsx b/frontend/src/components/Truncate.tsx new file mode 100644 index 0000000..b4ad137 --- /dev/null +++ b/frontend/src/components/Truncate.tsx @@ -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 { + 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)}…` + ) : ( +
{text}
+ ); + } +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 15dd5dc..e162455 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -10,6 +10,7 @@ export * from './Tile'; export * from './Ago'; export * from './OfflineIndicator'; export * from './Sparkline'; +export * from './Truncate'; export * from './Tooltip'; export * from './Filter'; export * from './PolkadotIcon'; diff --git a/frontend/src/icons/crab.svg b/frontend/src/icons/crab.svg index 9cd9c04..c064f22 100644 --- a/frontend/src/icons/crab.svg +++ b/frontend/src/icons/crab.svg @@ -1,9 +1,6 @@ - - - Crab - Crab Network Logo - - + + + \ No newline at end of file diff --git a/frontend/src/icons/darwinia.svg b/frontend/src/icons/darwinia.svg index 0aa4283..4501201 100644 --- a/frontend/src/icons/darwinia.svg +++ b/frontend/src/icons/darwinia.svg @@ -1,9 +1,6 @@ - - - Darwinia - Darwinia Network Logo - - + + + \ No newline at end of file diff --git a/frontend/src/icons/edgeware.svg b/frontend/src/icons/edgeware.svg index 8d48ce0..b07efef 100644 --- a/frontend/src/icons/edgeware.svg +++ b/frontend/src/icons/edgeware.svg @@ -1,10 +1,10 @@ - - - - - - + + + + + + diff --git a/frontend/src/icons/polkadot-js.svg b/frontend/src/icons/polkadot-js.svg index 67fa1cd..6913710 100644 --- a/frontend/src/icons/polkadot-js.svg +++ b/frontend/src/icons/polkadot-js.svg @@ -1 +1 @@ - + diff --git a/frontend/src/state.ts b/frontend/src/state.ts index 724a684..fc5856d 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import { Types, Maybe, SortedCollection } from './common'; import { Column } from './components/List'; @@ -69,7 +70,7 @@ export class Node { public lon: Maybe; public city: Maybe; - private readonly subscriptions = new Set<(node: Node) => void>(); + private _changeRef = 0; private readonly subscriptionsConsensus = new Set<(node: Node) => void>(); constructor( @@ -188,29 +189,36 @@ export class Node { } } - public subscribe(handler: (node: Node) => void) { - this.subscriptions.add(handler); - } - - 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); + public get changeRef(): number { + return this._changeRef; } private trigger() { - for (const handler of this.subscriptions.values()) { - handler(this); - } + this._changeRef += 1; } } +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 interface Settings { location: boolean; @@ -262,11 +270,8 @@ export interface State { } export type Update = ( - changes: Pick | null + changes: Pick ) => Readonly; -export type UpdateBound = ( - changes: Pick | null -) => void; export interface ChainData { label: Types.ChainLabel; diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index b56cbce..faee909 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -95,3 +95,19 @@ export function setHashData(val: HashData) { 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; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6259913..5073e0d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -184,10 +184,6 @@ lodash "^4.17.13" 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": version "1.0.0" resolved "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -246,11 +242,6 @@ dependencies: 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": version "4.11.6" resolved "https://registry.npmjs.org/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c" @@ -302,13 +293,6 @@ dependencies: "@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": version "16.9.34" 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" text-table "0.2.0" -react-dom@^16.13.1: - version "16.13.1" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" - integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== +react-dom@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" + integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" + scheduler "^0.20.1" react-error-overlay@^4.0.1: version "4.0.1" @@ -7820,21 +7803,13 @@ react-scripts-ts@3.1.0: optionalDependencies: fsevents "^1.1.3" -react-svg@4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/react-svg/-/react-svg-4.1.1.tgz#6151831e6f03e1ef5a090c61b12c30aa48185425" - integrity sha512-PAFIRcXnOT2VXcP31DZJ+Xw11NBkvvDAfnAm5C2iVwVbngFUXPbqEKT0V4jquAOGE7ZDF4/Ok0xKInCkSKq1Iw== - 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== +react@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" + integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" read-pkg-up@^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" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== +scheduler@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" + integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"