mirror of
https://github.com/pezkuwichain/pezkuwi-telemetry.git
synced 2026-04-30 17:48:00 +00:00
Turbo Render (#298)
* More responsive React updates on scroll * `Icon`s now use shadow dom * Faster Sparkline * Recycle table rows * Separate Header from Chain to avoid vdom diffing * Separate THead from Row.HEADER to avoid vdom diffing * Throttle rendering updates on chain tabs, also styles * Minor tweaks and fixes * Created components for all columns * Wrapping up Column refactor * Rename Row--td to Column * Lazy `Ago` * Update styles for faster layouting * Minor cleanup * Fix Connection * Use shadow DOM in `PolkadotIcon` * Comments and tweaks for the List component * Faster Tooltip and Truncate * Minor tweaks * Tooltiped columns can now be copied * Future-proof Connection * Remove the <div> wrapper from Icon * Fix dash on missing graph data * Clean up some SVGs * Cleanup and comments * Localize the use of `previousKeys` to `recalculateKeys` * Custom appState disjoint from React component state * Make appState and appUpdate refs readonly * Cleanup
This commit is contained in:
@@ -38,15 +38,29 @@ export class Ago extends React.Component<Ago.Props, Ago.State> {
|
||||
|
||||
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<Ago.Props, Ago.State> {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const ago = Math.max(this.state.now - this.props.when, 0) / 1000;
|
||||
return (
|
||||
<span title={new Date(this.props.when).toUTCString()}>{this.agoStr}</span>
|
||||
);
|
||||
}
|
||||
|
||||
private stringify(when: number, now: number): string {
|
||||
const ago = Math.max(now - when, 0) / 1000;
|
||||
|
||||
let agoStr: string;
|
||||
|
||||
@@ -83,8 +103,6 @@ export class Ago extends React.Component<Ago.Props, Ago.State> {
|
||||
agoStr += ' ago';
|
||||
}
|
||||
|
||||
return (
|
||||
<span title={new Date(this.props.when).toUTCString()}>{agoStr}</span>
|
||||
);
|
||||
return agoStr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,12 @@ export class AllChains extends React.Component<AllChains.Props, {}> {
|
||||
const close = subscribed ? `#list/${subscribed}` : '#list';
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<a className="AllChains-overlay" href={close} />
|
||||
<div className="AllChains">
|
||||
{chains.map((chain) => this.renderChain(chain))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AppState>;
|
||||
appUpdate: AppUpdate;
|
||||
connection: Promise<Connection>;
|
||||
settings: PersistentObject<AppState.Settings>;
|
||||
pins: PersistentSet<Types.NodeName>;
|
||||
@@ -64,56 +56,14 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
|
||||
|
||||
return (
|
||||
<div className="Chain">
|
||||
<div className="Chain-header">
|
||||
<Tile icon={blockIcon} title="Best Block">
|
||||
#{formatNumber(best)}
|
||||
</Tile>
|
||||
<Tile icon={finalizedIcon} title="Finalized Block">
|
||||
#{formatNumber(finalized)}
|
||||
</Tile>
|
||||
<Tile icon={blockTimeIcon} title="Average Time">
|
||||
{blockAverage == null
|
||||
? '-'
|
||||
: secondsWithPrecision(blockAverage / 1000)}
|
||||
</Tile>
|
||||
<Tile icon={lastTimeIcon} title="Last Block">
|
||||
<Ago when={blockTimestamp} />
|
||||
</Tile>
|
||||
<div className="Chain-tabs">
|
||||
<Tab
|
||||
icon={listIcon}
|
||||
label="List"
|
||||
display="list"
|
||||
tab=""
|
||||
current={currentTab}
|
||||
setDisplay={this.setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={worldIcon}
|
||||
label="Map"
|
||||
display="map"
|
||||
tab="map"
|
||||
current={currentTab}
|
||||
setDisplay={this.setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={consensusIcon}
|
||||
label="Consensus"
|
||||
display="consensus"
|
||||
tab="consensus"
|
||||
current={currentTab}
|
||||
setDisplay={this.setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={settingsIcon}
|
||||
label="Settings"
|
||||
display="settings"
|
||||
tab="settings"
|
||||
current={currentTab}
|
||||
setDisplay={this.setDisplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Header
|
||||
best={best}
|
||||
finalized={finalized}
|
||||
blockAverage={blockAverage}
|
||||
blockTimestamp={blockTimestamp}
|
||||
currentTab={currentTab}
|
||||
setDisplay={this.setDisplay}
|
||||
/>
|
||||
<div className="Chain-content-container">
|
||||
<div className="Chain-content">{this.renderContent()}</div>
|
||||
</div>
|
||||
@@ -128,14 +78,19 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
|
||||
return <Settings settings={this.props.settings} />;
|
||||
}
|
||||
|
||||
const { appState, connection, pins, sortBy } = this.props;
|
||||
const { appState, appUpdate, connection, pins, sortBy } = this.props;
|
||||
|
||||
if (display === 'consensus') {
|
||||
return <Consensus appState={appState} connection={connection} />;
|
||||
}
|
||||
|
||||
return display === 'list' ? (
|
||||
<List appState={appState} pins={pins} sortBy={sortBy} />
|
||||
<List
|
||||
appState={appState}
|
||||
appUpdate={appUpdate}
|
||||
pins={pins}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
) : (
|
||||
<Map appState={appState} />
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../../common';
|
||||
import { formatNumber, secondsWithPrecision } from '../../utils';
|
||||
import { Tab, Chain } from './';
|
||||
import { Tile, Ago } from '../';
|
||||
|
||||
import blockIcon from '../../icons/cube.svg';
|
||||
import finalizedIcon from '../../icons/cube-alt.svg';
|
||||
import blockTimeIcon from '../../icons/history.svg';
|
||||
import lastTimeIcon from '../../icons/watch.svg';
|
||||
import listIcon from '../../icons/list-alt-regular.svg';
|
||||
import worldIcon from '../../icons/location.svg';
|
||||
import settingsIcon from '../../icons/settings.svg';
|
||||
import consensusIcon from '../../icons/cube-alt.svg';
|
||||
|
||||
import './Header.css';
|
||||
|
||||
export namespace Header {
|
||||
export interface Props {
|
||||
best: Types.BlockNumber;
|
||||
finalized: Types.BlockNumber;
|
||||
blockTimestamp: Types.Timestamp;
|
||||
blockAverage: Maybe<Types.Milliseconds>;
|
||||
currentTab: Chain.Display;
|
||||
setDisplay: (display: Chain.Display) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export class Header extends React.Component<Header.Props, {}> {
|
||||
public shouldComponentUpdate(nextProps: Header.Props) {
|
||||
return (
|
||||
this.props.best !== nextProps.best ||
|
||||
this.props.finalized !== nextProps.finalized ||
|
||||
this.props.blockTimestamp !== nextProps.blockTimestamp ||
|
||||
this.props.blockAverage !== nextProps.blockAverage ||
|
||||
this.props.currentTab !== nextProps.currentTab
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { best, finalized, blockTimestamp, blockAverage } = this.props;
|
||||
const { currentTab, setDisplay } = this.props;
|
||||
|
||||
return (
|
||||
<div className="Header">
|
||||
<Tile icon={blockIcon} title="Best Block">
|
||||
#{formatNumber(best)}
|
||||
</Tile>
|
||||
<Tile icon={finalizedIcon} title="Finalized Block">
|
||||
#{formatNumber(finalized)}
|
||||
</Tile>
|
||||
<Tile icon={blockTimeIcon} title="Average Time">
|
||||
{blockAverage == null
|
||||
? '-'
|
||||
: secondsWithPrecision(blockAverage / 1000)}
|
||||
</Tile>
|
||||
<Tile icon={lastTimeIcon} title="Last Block">
|
||||
<Ago when={blockTimestamp} />
|
||||
</Tile>
|
||||
<div className="Header-tabs">
|
||||
<Tab
|
||||
icon={listIcon}
|
||||
label="List"
|
||||
display="list"
|
||||
tab=""
|
||||
current={currentTab}
|
||||
setDisplay={setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={worldIcon}
|
||||
label="Map"
|
||||
display="map"
|
||||
tab="map"
|
||||
current={currentTab}
|
||||
setDisplay={setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={consensusIcon}
|
||||
label="Consensus"
|
||||
display="consensus"
|
||||
tab="consensus"
|
||||
current={currentTab}
|
||||
setDisplay={setDisplay}
|
||||
/>
|
||||
<Tab
|
||||
icon={settingsIcon}
|
||||
label="Settings"
|
||||
display="settings"
|
||||
tab="settings"
|
||||
current={currentTab}
|
||||
setDisplay={setDisplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ export class Tab extends React.Component<Tab.Props, {}> {
|
||||
const className = highlight ? 'Chain-Tab-on Chain-Tab' : 'Chain-Tab';
|
||||
|
||||
return (
|
||||
<div className={className} onClick={this.onClick}>
|
||||
<Icon src={icon} alt={label} />
|
||||
<div className={className} onClick={this.onClick} title={label}>
|
||||
<Icon src={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './Chain';
|
||||
export * from './Tab';
|
||||
export * from './Header';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Chains.Props, {}> {
|
||||
private lastRender = performance.now();
|
||||
private clicked: Maybe<Types.ChainLabel>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Chains.Props) {
|
||||
if (nextProps.subscribed !== this.clicked) {
|
||||
this.clicked = nextProps.subscribed;
|
||||
}
|
||||
|
||||
return (
|
||||
this.props.subscribed !== nextProps.subscribed ||
|
||||
performance.now() - this.lastRender > RENDER_THROTTLE
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
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<Chains.Props, {}> {
|
||||
|
||||
return (
|
||||
<div className="Chains">
|
||||
{chains.map((chain) => this.renderChain(chain))}
|
||||
<a className="Chains-all-chains" href={allChainsHref}>
|
||||
<Icon src={listIcon} alt="All Chains" />
|
||||
{chains.slice(0, VISIBLE_CAP).map((chain) => this.renderChain(chain))}
|
||||
<a
|
||||
className="Chains-all-chains"
|
||||
href={allChainsHref}
|
||||
title="All Chains"
|
||||
>
|
||||
<Icon src={listIcon} />
|
||||
</a>
|
||||
<a
|
||||
className="Chains-fork-me"
|
||||
href="https://github.com/paritytech/substrate-telemetry"
|
||||
target="_blank"
|
||||
title="Fork Me!"
|
||||
>
|
||||
<Icon src={githubIcon} alt="Fork Me!" />
|
||||
<Icon src={githubIcon} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
@@ -43,10 +69,11 @@ export class Chains extends React.Component<Chains.Props, {}> {
|
||||
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 (
|
||||
<a
|
||||
@@ -54,7 +81,7 @@ export class Chains extends React.Component<Chains.Props, {}> {
|
||||
className={className}
|
||||
onClick={this.subscribe.bind(this, label)}
|
||||
>
|
||||
{label}{' '}
|
||||
{label}
|
||||
<span className="Chains-node-count" title="Node Count">
|
||||
{nodeCount}
|
||||
</span>
|
||||
@@ -63,6 +90,11 @@ export class Chains extends React.Component<Chains.Props, {}> {
|
||||
}
|
||||
|
||||
private async subscribe(chain: Types.ChainLabel) {
|
||||
if (chain === this.clicked) {
|
||||
return;
|
||||
}
|
||||
this.clicked = chain;
|
||||
|
||||
const connection = await this.props.connection;
|
||||
|
||||
connection.subscribe(chain);
|
||||
|
||||
@@ -195,9 +195,9 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
|
||||
];
|
||||
|
||||
finalizedInfo = matrice.ImplicitFinalized ? (
|
||||
<Icon className="implicit" src={finalizedIcon} alt="" />
|
||||
<Icon className="implicit" src={finalizedIcon} />
|
||||
) : (
|
||||
<Icon className="explicit" src={finalizedIcon} alt="" />
|
||||
<Icon className="explicit" src={finalizedIcon} />
|
||||
);
|
||||
|
||||
finalizedHash = matrice.FinalizedHash ? (
|
||||
@@ -301,25 +301,17 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
|
||||
let statPrecommit;
|
||||
|
||||
if (implicitPrevote) {
|
||||
statPrevote = (
|
||||
<Icon src={checkIcon} className="implicit" alt="Implicit Prevote" />
|
||||
);
|
||||
statPrevote = <Icon src={checkIcon} className="implicit" />;
|
||||
}
|
||||
if (implicitPrecommit) {
|
||||
statPrecommit = (
|
||||
<Icon src={checkIcon} className="implicit" alt="Implicit Precommit" />
|
||||
);
|
||||
statPrecommit = <Icon src={checkIcon} className="implicit" />;
|
||||
}
|
||||
|
||||
if (prevote) {
|
||||
statPrevote = (
|
||||
<Icon src={checkIcon} className="explicit" alt="Prevote" />
|
||||
);
|
||||
statPrevote = <Icon src={checkIcon} className="explicit" />;
|
||||
}
|
||||
if (precommit) {
|
||||
statPrecommit = (
|
||||
<Icon src={checkIcon} className="explicit" alt="Precommit" />
|
||||
);
|
||||
statPrecommit = <Icon src={checkIcon} className="explicit" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -329,7 +321,7 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <Icon src={hatchingIcon} className="hatching" alt="" />;
|
||||
return <Icon src={hatchingIcon} className="hatching" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class Filter extends React.Component<Filter.Props, {}> {
|
||||
|
||||
private filterInput: HTMLInputElement;
|
||||
|
||||
public componentWillMount() {
|
||||
public componentDidMount() {
|
||||
window.addEventListener('keyup', this.onWindowKeyUp);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Icon svg {
|
||||
width: auto;
|
||||
.Icon svg, .Icon-symbol-root symbol {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
public shouldComponentUpdate(nextProps: Props) {
|
||||
let symbolId = 0;
|
||||
|
||||
// Lazily render the icon in the DOM, so that we can referenced
|
||||
// it by id using shadow DOM.
|
||||
function renderShadowIcon(src: string): string {
|
||||
let symbol = symbols.get(src);
|
||||
|
||||
if (!symbol) {
|
||||
symbol = `icon${symbolId}`;
|
||||
symbolId += 1;
|
||||
|
||||
symbols.set(src, symbol);
|
||||
|
||||
fetch(src).then(async (response) => {
|
||||
const html = await response.text();
|
||||
const temp = document.createElement('div');
|
||||
|
||||
temp.innerHTML = html;
|
||||
|
||||
const tempSVG = temp.querySelector('svg') as SVGSVGElement;
|
||||
const symEl = document.createElementNS(W3SVG, 'symbol');
|
||||
const viewBox = tempSVG.getAttribute('viewBox');
|
||||
|
||||
symEl.setAttribute('id', symbol as string);
|
||||
if (viewBox) {
|
||||
symEl.setAttribute('viewBox', viewBox);
|
||||
}
|
||||
|
||||
for (const child of Array.from(tempSVG.childNodes)) {
|
||||
symEl.appendChild(child);
|
||||
}
|
||||
|
||||
getSVGShadowRoot().appendChild(symEl);
|
||||
});
|
||||
}
|
||||
|
||||
return symbol;
|
||||
}
|
||||
|
||||
export class Icon extends React.Component<Icon.Props, {}> {
|
||||
public props: Icon.Props;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Icon.Props) {
|
||||
return (
|
||||
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 (
|
||||
<ReactSVG
|
||||
key={this.props.src}
|
||||
title={alt}
|
||||
className={`Icon ${className || ''}`}
|
||||
path={src}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<svg className={`Icon ${className || ''}`} onClick={onClick}>
|
||||
<use href={`#${symbol}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe, timestamp } from '../../common';
|
||||
import { State, Node } from '../../state';
|
||||
import { Truncate } from './';
|
||||
import { Ago, Icon, Tooltip, Sparkline, PolkadotIcon } from '../';
|
||||
import {
|
||||
formatNumber,
|
||||
getHashData,
|
||||
milliOrSecond,
|
||||
secondsWithPrecision,
|
||||
} from '../../utils';
|
||||
|
||||
export interface Column {
|
||||
label: string;
|
||||
icon: string;
|
||||
width?: number;
|
||||
setting?: keyof State.Settings;
|
||||
sortBy?: (node: Node) => any;
|
||||
render: (node: Node) => React.ReactElement<any> | string;
|
||||
}
|
||||
|
||||
import nodeIcon from '../../icons/server.svg';
|
||||
import nodeLocationIcon from '../../icons/location.svg';
|
||||
import nodeValidatorIcon from '../../icons/shield.svg';
|
||||
import nodeTypeIcon from '../../icons/terminal.svg';
|
||||
import networkIdIcon from '../../icons/fingerprint.svg';
|
||||
import peersIcon from '../../icons/broadcast.svg';
|
||||
import transactionsIcon from '../../icons/inbox.svg';
|
||||
import blockIcon from '../../icons/cube.svg';
|
||||
import finalizedIcon from '../../icons/cube-alt.svg';
|
||||
import blockHashIcon from '../../icons/file-binary.svg';
|
||||
import blockTimeIcon from '../../icons/history.svg';
|
||||
import propagationTimeIcon from '../../icons/dashboard.svg';
|
||||
import lastTimeIcon from '../../icons/watch.svg';
|
||||
import uploadIcon from '../../icons/cloud-upload.svg';
|
||||
import downloadIcon from '../../icons/cloud-download.svg';
|
||||
import stateIcon from '../../icons/git-branch.svg';
|
||||
import networkIcon from '../../icons/network.svg';
|
||||
import uptimeIcon from '../../icons/pulse.svg';
|
||||
import externalLinkIcon from '../../icons/link-external.svg';
|
||||
|
||||
import parityPolkadotIcon from '../../icons/dot.svg';
|
||||
import paritySubstrateIcon from '../../icons/substrate.svg';
|
||||
import polkadotJsIcon from '../../icons/polkadot-js.svg';
|
||||
import airalabRobonomicsIcon from '../../icons/robonomics.svg';
|
||||
import chainXIcon from '../../icons/chainx.svg';
|
||||
import edgewareIcon from '../../icons/edgeware.svg';
|
||||
import joystreamIcon from '../../icons/joystream.svg';
|
||||
import ladderIcon from '../../icons/laddernetwork.svg';
|
||||
import cennznetIcon from '../../icons/cennznet.svg';
|
||||
import crabIcon from '../../icons/crab.svg';
|
||||
import darwiniaIcon from '../../icons/darwinia.svg';
|
||||
import turingIcon from '../../icons/turingnetwork.svg';
|
||||
import dothereumIcon from '../../icons/dothereum.svg';
|
||||
import katalchainIcon from '../../icons/katalchain.svg';
|
||||
import bifrostIcon from '../../icons/bifrost.svg';
|
||||
import totemIcon from '../../icons/totem.svg';
|
||||
import nodleIcon from '../../icons/nodle.svg';
|
||||
import zeroIcon from '../../icons/zero.svg';
|
||||
|
||||
import unknownImplementationIcon from '../../icons/question-solid.svg';
|
||||
|
||||
const ICONS = {
|
||||
'parity-polkadot': parityPolkadotIcon,
|
||||
'Parity Polkadot': parityPolkadotIcon,
|
||||
'polkadot-js': polkadotJsIcon,
|
||||
'airalab-robonomics': airalabRobonomicsIcon,
|
||||
'substrate-node': paritySubstrateIcon,
|
||||
'Substrate Node': paritySubstrateIcon,
|
||||
'edgeware-node': edgewareIcon,
|
||||
'Edgeware Node': edgewareIcon,
|
||||
'joystream-node': joystreamIcon,
|
||||
ChainX: chainXIcon,
|
||||
'ladder-node': ladderIcon,
|
||||
'cennznet-node': cennznetIcon,
|
||||
'Darwinia Crab': crabIcon,
|
||||
Darwinia: darwiniaIcon,
|
||||
'turing-node': turingIcon,
|
||||
dothereum: dothereumIcon,
|
||||
katalchain: katalchainIcon,
|
||||
'bifrost-node': bifrostIcon,
|
||||
'totem-meccano-node': totemIcon,
|
||||
Totem: totemIcon,
|
||||
'Nodle Chain Node': nodleIcon,
|
||||
subzero: zeroIcon,
|
||||
};
|
||||
|
||||
export namespace Column {
|
||||
export const NAME: Column = {
|
||||
label: 'Node',
|
||||
icon: nodeIcon,
|
||||
sortBy: ({ sortableName }) => sortableName,
|
||||
render: ({ name }) => <Truncate text={name} position="left" />,
|
||||
};
|
||||
|
||||
export const VALIDATOR: Column = {
|
||||
label: 'Validator',
|
||||
icon: nodeValidatorIcon,
|
||||
width: 16,
|
||||
setting: 'validator',
|
||||
sortBy: ({ validator }) => validator || '',
|
||||
render: ({ validator }) => {
|
||||
return validator ? (
|
||||
<Tooltip text={validator} copy={true}>
|
||||
<span className="Row-validator">
|
||||
<PolkadotIcon account={validator} size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCATION: Column = {
|
||||
label: 'Location',
|
||||
icon: nodeLocationIcon,
|
||||
width: 140,
|
||||
setting: 'location',
|
||||
sortBy: ({ city }) => city || '',
|
||||
render: ({ city }) =>
|
||||
city ? <Truncate position="left" text={city} /> : '-',
|
||||
};
|
||||
|
||||
export const IMPLEMENTATION: Column = {
|
||||
label: 'Implementation',
|
||||
icon: nodeTypeIcon,
|
||||
width: 90,
|
||||
setting: 'implementation',
|
||||
sortBy: ({ sortableVersion }) => sortableVersion,
|
||||
render: ({ implementation, version }) => {
|
||||
const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?'];
|
||||
const implIcon = ICONS[implementation] || unknownImplementationIcon;
|
||||
|
||||
return (
|
||||
<Tooltip text={`${implementation} v${version}`}>
|
||||
<Icon src={implIcon} /> {semver}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NETWORK_ID: Column = {
|
||||
label: 'Network ID',
|
||||
icon: networkIdIcon,
|
||||
width: 90,
|
||||
setting: 'networkId',
|
||||
sortBy: ({ networkId }) => networkId || '',
|
||||
render: ({ networkId }) =>
|
||||
networkId ? <Truncate position="left" text={networkId} /> : '-',
|
||||
};
|
||||
|
||||
export const PEERS: Column = {
|
||||
label: 'Peer Count',
|
||||
icon: peersIcon,
|
||||
width: 26,
|
||||
setting: 'peers',
|
||||
sortBy: ({ peers }) => peers,
|
||||
render: ({ peers }) => `${peers}`,
|
||||
};
|
||||
|
||||
export const TXS: Column = {
|
||||
label: 'Transactions in Queue',
|
||||
icon: transactionsIcon,
|
||||
width: 26,
|
||||
setting: 'txs',
|
||||
sortBy: ({ txs }) => txs,
|
||||
render: ({ txs }) => `${txs}`,
|
||||
};
|
||||
|
||||
export const UPLOAD: Column = {
|
||||
label: 'Upload Bandwidth',
|
||||
icon: uploadIcon,
|
||||
width: 40,
|
||||
setting: 'upload',
|
||||
sortBy: ({ upload }) => (upload.length < 3 ? 0 : upload[upload.length - 1]),
|
||||
render: ({ upload, chartstamps }) => {
|
||||
if (upload.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBandwidth}
|
||||
values={upload}
|
||||
stamps={chartstamps}
|
||||
minScale={BANDWIDTH_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DOWNLOAD: Column = {
|
||||
label: 'Download Bandwidth',
|
||||
icon: downloadIcon,
|
||||
width: 40,
|
||||
setting: 'download',
|
||||
sortBy: ({ download }) =>
|
||||
download.length < 3 ? 0 : download[download.length - 1],
|
||||
render: ({ download, chartstamps }) => {
|
||||
if (download.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBandwidth}
|
||||
values={download}
|
||||
stamps={chartstamps}
|
||||
minScale={BANDWIDTH_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const STATE_CACHE: Column = {
|
||||
label: 'State Cache Size',
|
||||
icon: stateIcon,
|
||||
width: 40,
|
||||
setting: 'stateCacheSize',
|
||||
sortBy: ({ stateCacheSize }) =>
|
||||
stateCacheSize.length < 3 ? 0 : stateCacheSize[stateCacheSize.length - 1],
|
||||
render: ({ stateCacheSize, chartstamps }) => {
|
||||
if (stateCacheSize.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBytes}
|
||||
values={stateCacheSize}
|
||||
stamps={chartstamps}
|
||||
minScale={MEMORY_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const BLOCK_NUMBER: Column = {
|
||||
label: 'Block',
|
||||
icon: blockIcon,
|
||||
width: 88,
|
||||
setting: 'blocknumber',
|
||||
sortBy: ({ height }) => height || 0,
|
||||
render: ({ height }) => `#${formatNumber(height)}`,
|
||||
};
|
||||
|
||||
export const BLOCK_HASH: Column = {
|
||||
label: 'Block Hash',
|
||||
icon: blockHashIcon,
|
||||
width: 154,
|
||||
setting: 'blockhash',
|
||||
sortBy: ({ hash }) => hash || '',
|
||||
render: ({ hash }) => <Truncate position="right" text={hash} copy={true} />,
|
||||
};
|
||||
|
||||
export const FINALIZED: Column = {
|
||||
label: 'Finalized Block',
|
||||
icon: finalizedIcon,
|
||||
width: 88,
|
||||
setting: 'finalized',
|
||||
sortBy: ({ finalized }) => finalized || 0,
|
||||
render: ({ finalized }) => `#${formatNumber(finalized)}`,
|
||||
};
|
||||
|
||||
export const FINALIZED_HASH: Column = {
|
||||
label: 'Finalized Block Hash',
|
||||
icon: blockHashIcon,
|
||||
width: 154,
|
||||
setting: 'finalizedhash',
|
||||
sortBy: ({ finalizedHash }) => finalizedHash || '',
|
||||
render: ({ finalizedHash }) => (
|
||||
<Truncate position="right" text={finalizedHash} copy={true} />
|
||||
),
|
||||
};
|
||||
|
||||
export const BLOCK_TIME: Column = {
|
||||
label: 'Block Time',
|
||||
icon: blockTimeIcon,
|
||||
width: 80,
|
||||
setting: 'blocktime',
|
||||
sortBy: ({ blockTime }) => (blockTime == null ? Infinity : blockTime),
|
||||
render: ({ blockTime }) => `${secondsWithPrecision(blockTime / 1000)}`,
|
||||
};
|
||||
|
||||
export const BLOCK_PROPAGATION: Column = {
|
||||
label: 'Block Propagation Time',
|
||||
icon: propagationTimeIcon,
|
||||
width: 58,
|
||||
setting: 'blockpropagation',
|
||||
sortBy: ({ propagationTime }) =>
|
||||
propagationTime == null ? Infinity : propagationTime,
|
||||
render: ({ propagationTime }) =>
|
||||
propagationTime == null ? '∞' : milliOrSecond(propagationTime),
|
||||
};
|
||||
|
||||
export const BLOCK_LAST_TIME: Column = {
|
||||
label: 'Last Block Time',
|
||||
icon: lastTimeIcon,
|
||||
width: 100,
|
||||
setting: 'blocklasttime',
|
||||
sortBy: ({ blockTimestamp }) => blockTimestamp || 0,
|
||||
render: ({ blockTimestamp }) => <Ago when={blockTimestamp} />,
|
||||
};
|
||||
|
||||
export const UPTIME: Column = {
|
||||
label: 'Node Uptime',
|
||||
icon: uptimeIcon,
|
||||
width: 58,
|
||||
setting: 'uptime',
|
||||
sortBy: ({ connectedAt }) => connectedAt || 0,
|
||||
render: ({ connectedAt }) => <Ago when={connectedAt} justTime={true} />,
|
||||
};
|
||||
|
||||
export const NETWORK_STATE: Column = {
|
||||
label: 'NetworkState',
|
||||
icon: networkIcon,
|
||||
width: 16,
|
||||
setting: 'networkstate',
|
||||
render: ({ id }) => {
|
||||
const chainLabel = getHashData().chain;
|
||||
|
||||
if (!chainLabel) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`;
|
||||
return (
|
||||
<a href={uri} target="_blank">
|
||||
<Icon src={externalLinkIcon} />
|
||||
</a>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
|
||||
const BANDWIDTH_SCALE = 1024 * 1024;
|
||||
const MEMORY_SCALE = 2 * 1024 * 1024;
|
||||
const URI_BASE =
|
||||
window.location.protocol === 'https:'
|
||||
? `/network_state/`
|
||||
: `http://${window.location.hostname}:8000/network_state/`;
|
||||
|
||||
function formatStamp(stamp: Types.Timestamp): string {
|
||||
const passed = ((timestamp() - stamp) / 1000) | 0;
|
||||
|
||||
const hours = (passed / 3600) | 0;
|
||||
const minutes = ((passed % 3600) / 60) | 0;
|
||||
const seconds = passed % 60 | 0;
|
||||
|
||||
return hours
|
||||
? `${hours}h ago`
|
||||
: minutes
|
||||
? `${minutes}m ago`
|
||||
: `${seconds}s ago`;
|
||||
}
|
||||
|
||||
function formatMemory(kbs: number, stamp: Maybe<Types.Timestamp>): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
const mbs = (kbs / 1024) | 0;
|
||||
|
||||
if (mbs >= 1000) {
|
||||
return `${(mbs / 1024).toFixed(1)} GB${ago}`;
|
||||
} else {
|
||||
return `${mbs} MB${ago}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number, stamp: Maybe<Types.Timestamp>): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB${ago}`;
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB${ago}`;
|
||||
} else if (bytes >= 1000) {
|
||||
return `${(bytes / 1024).toFixed(1)} kB${ago}`;
|
||||
} else {
|
||||
return `${bytes} B${ago}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBandwidth(bps: number, stamp: Maybe<Types.Timestamp>): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
|
||||
if (bps >= 1024 * 1024) {
|
||||
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`;
|
||||
} else if (bps >= 1000) {
|
||||
return `${(bps / 1024).toFixed(1)} kB/s${ago}`;
|
||||
} else {
|
||||
return `${bps | 0} B/s${ago}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCPU(cpu: number, stamp: Maybe<Types.Timestamp>): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
const fractionDigits = cpu > 100 ? 0 : cpu > 10 ? 1 : cpu > 1 ? 2 : 3;
|
||||
|
||||
return `${cpu.toFixed(fractionDigits)}%${ago}`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Truncate, Tooltip } from '../../';
|
||||
import icon from '../../../icons/file-binary.svg';
|
||||
|
||||
export class BlockHashColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Block Hash';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 154;
|
||||
public static readonly setting = 'blockhash';
|
||||
public static readonly sortBy = ({ hash }: Node) => hash || '';
|
||||
|
||||
private data: Maybe<string>;
|
||||
private copy: Maybe<Tooltip.CopyCallback>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.hash;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hash } = this.props.node;
|
||||
|
||||
this.data = hash;
|
||||
|
||||
return (
|
||||
<td className="Column" onClick={this.onClick}>
|
||||
<Tooltip text={hash} position="right" copy={this.onCopy} />
|
||||
<Truncate text={hash} chars={16} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
private onCopy = (copy: Tooltip.CopyCallback) => {
|
||||
this.copy = copy;
|
||||
};
|
||||
|
||||
private onClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.copy != null) {
|
||||
this.copy();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { formatNumber } from '../../../utils';
|
||||
import icon from '../../../icons/cube.svg';
|
||||
|
||||
export class BlockNumberColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Block';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 88;
|
||||
public static readonly setting = 'blocknumber';
|
||||
public static readonly sortBy = ({ height }: Node) => height;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.height;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { height } = this.props.node;
|
||||
|
||||
this.data = height;
|
||||
|
||||
return <td className="Column">{`#${formatNumber(height)}`}</td>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { milliOrSecond } from '../../../utils';
|
||||
import icon from '../../../icons/dashboard.svg';
|
||||
|
||||
export class BlockPropagationColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Block Propagation Time';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 58;
|
||||
public static readonly setting = 'blockpropagation';
|
||||
public static readonly sortBy = ({ propagationTime }: Node) =>
|
||||
propagationTime == null ? Infinity : propagationTime;
|
||||
|
||||
private data: Maybe<number>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.propagationTime;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { propagationTime } = this.props.node;
|
||||
const print =
|
||||
propagationTime == null ? '∞' : milliOrSecond(propagationTime);
|
||||
|
||||
this.data = propagationTime;
|
||||
|
||||
return <td className="Column">{print}</td>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { secondsWithPrecision } from '../../../utils';
|
||||
import icon from '../../../icons/history.svg';
|
||||
|
||||
export class BlockTimeColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Block Time';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 80;
|
||||
public static readonly setting = 'blocktime';
|
||||
public static readonly sortBy = ({ blockTime }: Node) =>
|
||||
blockTime == null ? Infinity : blockTime;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.blockTime;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { blockTime } = this.props.node;
|
||||
|
||||
this.data = blockTime;
|
||||
|
||||
return (
|
||||
<td className="Column">{`${secondsWithPrecision(blockTime / 1000)}`}</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.Column {
|
||||
text-align: left;
|
||||
padding: 6px 13px;
|
||||
height: 19px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Column-truncate {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 6px 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Column-Tooltip {
|
||||
position: initial !important;
|
||||
padding: inherit !important;
|
||||
}
|
||||
|
||||
.Column-validator {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Column-validator:hover {
|
||||
transform: scale(2);
|
||||
}
|
||||
|
||||
.Column--a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.Column--a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe, timestamp } from '../../../common';
|
||||
import { Node } from '../../../state';
|
||||
|
||||
import './Column.css';
|
||||
|
||||
import {
|
||||
NameColumn,
|
||||
ValidatorColumn,
|
||||
LocationColumn,
|
||||
ImplementationColumn,
|
||||
NetworkIdColumn,
|
||||
PeersColumn,
|
||||
TxsColumn,
|
||||
UploadColumn,
|
||||
DownloadColumn,
|
||||
StateCacheColumn,
|
||||
BlockNumberColumn,
|
||||
BlockHashColumn,
|
||||
FinalizedBlockColumn,
|
||||
FinalizedHashColumn,
|
||||
BlockTimeColumn,
|
||||
BlockPropagationColumn,
|
||||
LastBlockColumn,
|
||||
UptimeColumn,
|
||||
NetworkStateColumn,
|
||||
} from './';
|
||||
|
||||
export type Column =
|
||||
| typeof NameColumn
|
||||
| typeof ValidatorColumn
|
||||
| typeof LocationColumn
|
||||
| typeof ImplementationColumn
|
||||
| typeof NetworkIdColumn
|
||||
| typeof PeersColumn
|
||||
| typeof TxsColumn
|
||||
| typeof UploadColumn
|
||||
| typeof DownloadColumn
|
||||
| typeof StateCacheColumn
|
||||
| typeof BlockNumberColumn
|
||||
| typeof BlockHashColumn
|
||||
| typeof FinalizedBlockColumn
|
||||
| typeof FinalizedHashColumn
|
||||
| typeof BlockTimeColumn
|
||||
| typeof BlockPropagationColumn
|
||||
| typeof LastBlockColumn
|
||||
| typeof UptimeColumn
|
||||
| typeof NetworkStateColumn;
|
||||
|
||||
export namespace Column {
|
||||
export interface Props {
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
stamp: Maybe<Types.Timestamp>
|
||||
): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB${ago}`;
|
||||
} else if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB${ago}`;
|
||||
} else if (bytes >= 1000) {
|
||||
return `${(bytes / 1024).toFixed(1)} kB${ago}`;
|
||||
} else {
|
||||
return `${bytes} B${ago}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBandwidth(
|
||||
bps: number,
|
||||
stamp: Maybe<Types.Timestamp>
|
||||
): string {
|
||||
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
|
||||
|
||||
if (bps >= 1024 * 1024) {
|
||||
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`;
|
||||
} else if (bps >= 1000) {
|
||||
return `${(bps / 1024).toFixed(1)} kB/s${ago}`;
|
||||
} else {
|
||||
return `${bps | 0} B/s${ago}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BANDWIDTH_SCALE = 1024 * 1024;
|
||||
|
||||
function formatStamp(stamp: Types.Timestamp): string {
|
||||
const passed = ((timestamp() - stamp) / 1000) | 0;
|
||||
|
||||
const hours = (passed / 3600) | 0;
|
||||
const minutes = ((passed % 3600) / 60) | 0;
|
||||
const seconds = passed % 60 | 0;
|
||||
|
||||
return hours
|
||||
? `${hours}h ago`
|
||||
: minutes
|
||||
? `${minutes}m ago`
|
||||
: `${seconds}s ago`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe, timestamp } from '../../../common';
|
||||
import { Column, BANDWIDTH_SCALE } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Sparkline } from '../../';
|
||||
import icon from '../../../icons/cloud-download.svg';
|
||||
|
||||
export class DownloadColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Download Bandwidth';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 40;
|
||||
public static readonly setting = 'download';
|
||||
public static readonly sortBy = ({ download }: Node) =>
|
||||
download.length < 3 ? 0 : download[download.length - 1];
|
||||
|
||||
private data: Array<number> = [];
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Diffing by ref, as data is an immutable array
|
||||
return this.data !== nextProps.node.download;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { download, chartstamps } = this.props.node;
|
||||
|
||||
this.data = download;
|
||||
|
||||
if (download.length < 3) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={Column.formatBandwidth}
|
||||
values={download}
|
||||
stamps={chartstamps}
|
||||
minScale={BANDWIDTH_SCALE}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { formatNumber } from '../../../utils';
|
||||
import icon from '../../../icons/cube-alt.svg';
|
||||
|
||||
export class FinalizedBlockColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Finalized Block';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 88;
|
||||
public static readonly setting = 'finalized';
|
||||
public static readonly sortBy = ({ finalized }: Node) => finalized || 0;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.finalized;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { finalized } = this.props.node;
|
||||
|
||||
this.data = finalized;
|
||||
|
||||
return <td className="Column">{`#${formatNumber(finalized)}`}</td>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Truncate, Tooltip } from '../../';
|
||||
import icon from '../../../icons/file-binary.svg';
|
||||
|
||||
export class FinalizedHashColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Finalized Block Hash';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 154;
|
||||
public static readonly setting = 'finalizedhash';
|
||||
public static readonly sortBy = ({ finalizedHash }: Node) =>
|
||||
finalizedHash || '';
|
||||
|
||||
private data: Maybe<string>;
|
||||
private copy: Maybe<Tooltip.CopyCallback>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.finalizedHash;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { finalizedHash } = this.props.node;
|
||||
|
||||
this.data = finalizedHash;
|
||||
|
||||
return (
|
||||
<td className="Column" onClick={this.onClick}>
|
||||
<Tooltip text={finalizedHash} position="right" copy={this.onCopy} />
|
||||
<Truncate text={finalizedHash} chars={16} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
private onCopy = (copy: Tooltip.CopyCallback) => {
|
||||
this.copy = copy;
|
||||
};
|
||||
|
||||
private onClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.copy != null) {
|
||||
this.copy();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Tooltip, Icon } from '../../';
|
||||
import icon from '../../../icons/terminal.svg';
|
||||
|
||||
import parityPolkadotIcon from '../../../icons/dot.svg';
|
||||
import paritySubstrateIcon from '../../../icons/substrate.svg';
|
||||
import polkadotJsIcon from '../../../icons/polkadot-js.svg';
|
||||
import airalabRobonomicsIcon from '../../../icons/robonomics.svg';
|
||||
import chainXIcon from '../../../icons/chainx.svg';
|
||||
import edgewareIcon from '../../../icons/edgeware.svg';
|
||||
import joystreamIcon from '../../../icons/joystream.svg';
|
||||
import ladderIcon from '../../../icons/laddernetwork.svg';
|
||||
import cennznetIcon from '../../../icons/cennznet.svg';
|
||||
import crabIcon from '../../../icons/crab.svg';
|
||||
import darwiniaIcon from '../../../icons/darwinia.svg';
|
||||
import turingIcon from '../../../icons/turingnetwork.svg';
|
||||
import dothereumIcon from '../../../icons/dothereum.svg';
|
||||
import katalchainIcon from '../../../icons/katalchain.svg';
|
||||
import bifrostIcon from '../../../icons/bifrost.svg';
|
||||
import totemIcon from '../../../icons/totem.svg';
|
||||
import nodleIcon from '../../../icons/nodle.svg';
|
||||
import zeroIcon from '../../../icons/zero.svg';
|
||||
|
||||
const ICONS = {
|
||||
'parity-polkadot': parityPolkadotIcon,
|
||||
'Parity Polkadot': parityPolkadotIcon,
|
||||
'polkadot-js': polkadotJsIcon,
|
||||
'airalab-robonomics': airalabRobonomicsIcon,
|
||||
'substrate-node': paritySubstrateIcon,
|
||||
'Substrate Node': paritySubstrateIcon,
|
||||
'edgeware-node': edgewareIcon,
|
||||
'Edgeware Node': edgewareIcon,
|
||||
'joystream-node': joystreamIcon,
|
||||
ChainX: chainXIcon,
|
||||
'ladder-node': ladderIcon,
|
||||
'cennznet-node': cennznetIcon,
|
||||
'Darwinia Crab': crabIcon,
|
||||
Darwinia: darwiniaIcon,
|
||||
'turing-node': turingIcon,
|
||||
dothereum: dothereumIcon,
|
||||
katalchain: katalchainIcon,
|
||||
'bifrost-node': bifrostIcon,
|
||||
'totem-meccano-node': totemIcon,
|
||||
Totem: totemIcon,
|
||||
'Nodle Chain Node': nodleIcon,
|
||||
subzero: zeroIcon,
|
||||
};
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
|
||||
|
||||
export class ImplementationColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Implementation';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 90;
|
||||
public static readonly setting = 'implementation';
|
||||
public static readonly sortBy = ({ sortableVersion }: Node) =>
|
||||
sortableVersion;
|
||||
|
||||
private implementation: string;
|
||||
private version: string;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
if (this.props.node === nextProps.node) {
|
||||
// Implementation can't change unless we got a new node
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.implementation !== nextProps.node.implementation ||
|
||||
this.version !== nextProps.node.version
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { implementation, version } = this.props.node;
|
||||
|
||||
this.implementation = implementation;
|
||||
this.version = version;
|
||||
|
||||
const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?'];
|
||||
const implIcon = ICONS[implementation] || paritySubstrateIcon;
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Tooltip text={`${implementation} v${version}`} />
|
||||
<Icon src={implIcon} /> {semver}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Ago } from '../../';
|
||||
import icon from '../../../icons/watch.svg';
|
||||
|
||||
export class LastBlockColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Last Block Time';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 100;
|
||||
public static readonly setting = 'blocklasttime';
|
||||
public static readonly sortBy = ({ blockTimestamp }: Node) =>
|
||||
blockTimestamp || 0;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.blockTimestamp;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { blockTimestamp } = this.props.node;
|
||||
|
||||
this.data = blockTimestamp;
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Ago when={blockTimestamp} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Truncate, Tooltip } from '../../';
|
||||
import icon from '../../../icons/location.svg';
|
||||
|
||||
export class LocationColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Location';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 140;
|
||||
public static readonly setting = 'location';
|
||||
public static readonly sortBy = ({ city }: Node) => city || '';
|
||||
|
||||
private data: Maybe<string>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.city;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { city } = this.props.node;
|
||||
|
||||
this.data = city;
|
||||
|
||||
if (!city) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Tooltip text={city} position="left" />
|
||||
<Truncate text={city} chars={14} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Truncate, Tooltip } from '../../';
|
||||
import icon from '../../../icons/server.svg';
|
||||
|
||||
export class NameColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Node';
|
||||
public static readonly icon = icon;
|
||||
public static readonly setting = null;
|
||||
public static readonly width = null;
|
||||
public static readonly sortBy = ({ sortableName }: Node) => sortableName;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Node name only changes when the node does
|
||||
return this.props.node !== nextProps.node;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name } = this.props.node;
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Tooltip text={name} position="left" />
|
||||
<Truncate text={name} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Truncate } from '../../';
|
||||
import { Tooltip } from '../../';
|
||||
import icon from '../../../icons/fingerprint.svg';
|
||||
|
||||
export class NetworkIdColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Network ID';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 90;
|
||||
public static readonly setting = 'networkId';
|
||||
public static readonly sortBy = ({ networkId }: Node) => networkId || '';
|
||||
|
||||
private data: Maybe<string>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.networkId;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { networkId } = this.props.node;
|
||||
|
||||
this.data = networkId;
|
||||
|
||||
if (!networkId) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Tooltip text={networkId} position="left" />
|
||||
<Truncate text={networkId} chars={10} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Icon } from '../../';
|
||||
import icon from '../../../icons/network.svg';
|
||||
import externalLinkIcon from '../../../icons/link-external.svg';
|
||||
import { getHashData } from '../../../utils';
|
||||
|
||||
const URI_BASE =
|
||||
window.location.protocol === 'https:'
|
||||
? `/network_state/`
|
||||
: `http://${window.location.hostname}:8000/network_state/`;
|
||||
|
||||
export class NetworkStateColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Network State';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 16;
|
||||
public static readonly setting = 'networkstate';
|
||||
public static readonly sortBy = null;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Network state link changes when the node does
|
||||
return this.props.node !== nextProps.node;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id } = this.props.node;
|
||||
const chainLabel = getHashData().chain;
|
||||
|
||||
if (!chainLabel) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`;
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<a className="Column--a" href={uri} target="_blank">
|
||||
<Icon src={externalLinkIcon} />
|
||||
</a>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import icon from '../../../icons/broadcast.svg';
|
||||
|
||||
export class PeersColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Peer Count';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 26;
|
||||
public static readonly setting = 'peers';
|
||||
public static readonly sortBy = ({ peers }: Node) => peers;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.peers;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { peers } = this.props.node;
|
||||
|
||||
this.data = peers;
|
||||
|
||||
return <td className="Column">{peers}</td>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe, timestamp } from '../../../common';
|
||||
import { Column, BANDWIDTH_SCALE } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Sparkline } from '../../';
|
||||
import icon from '../../../icons/git-branch.svg';
|
||||
|
||||
export class StateCacheColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'State Cache Size';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 40;
|
||||
public static readonly setting = 'stateCacheSize';
|
||||
public static readonly sortBy = ({ stateCacheSize }: Node) =>
|
||||
stateCacheSize.length < 3 ? 0 : stateCacheSize[stateCacheSize.length - 1];
|
||||
|
||||
private data: Array<number> = [];
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Diffing by ref, as data is an immutable array
|
||||
return this.data !== nextProps.node.stateCacheSize;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { stateCacheSize, chartstamps } = this.props.node;
|
||||
|
||||
this.data = stateCacheSize;
|
||||
|
||||
if (stateCacheSize.length < 3) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={Column.formatBytes}
|
||||
values={stateCacheSize}
|
||||
stamps={chartstamps}
|
||||
minScale={BANDWIDTH_SCALE}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import icon from '../../../icons/inbox.svg';
|
||||
|
||||
export class TxsColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Transactions in Queue';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 26;
|
||||
public static readonly setting = 'txs';
|
||||
public static readonly sortBy = ({ txs }: Node) => txs;
|
||||
|
||||
private data = 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.txs;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { txs } = this.props.node;
|
||||
|
||||
this.data = txs;
|
||||
|
||||
return <td className="Column">{txs}</td>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe, timestamp } from '../../../common';
|
||||
import { Column, BANDWIDTH_SCALE } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Sparkline } from '../../';
|
||||
import icon from '../../../icons/cloud-upload.svg';
|
||||
|
||||
export class UploadColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Upload Bandwidth';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 40;
|
||||
public static readonly setting = 'upload';
|
||||
public static readonly sortBy = ({ upload }: Node) =>
|
||||
upload.length < 3 ? 0 : upload[upload.length - 1];
|
||||
|
||||
private data: Array<number> = [];
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Diffing by ref, as data is an immutable array
|
||||
return this.data !== nextProps.node.upload;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { upload, chartstamps } = this.props.node;
|
||||
|
||||
this.data = upload;
|
||||
|
||||
if (upload.length < 3) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={Column.formatBandwidth}
|
||||
values={upload}
|
||||
stamps={chartstamps}
|
||||
minScale={BANDWIDTH_SCALE}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Ago } from '../../';
|
||||
import icon from '../../../icons/pulse.svg';
|
||||
|
||||
export class UptimeColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Node Uptime';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 58;
|
||||
public static readonly setting = 'uptime';
|
||||
public static readonly sortBy = ({ connectedAt }: Node) => connectedAt || 0;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
// Uptime only changes when the node does
|
||||
return this.props.node !== nextProps.node;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { connectedAt } = this.props.node;
|
||||
|
||||
return (
|
||||
<td className="Column">
|
||||
<Ago when={connectedAt} justTime={true} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../../common';
|
||||
import { Column } from './';
|
||||
import { Node } from '../../../state';
|
||||
import { Tooltip, PolkadotIcon } from '../../';
|
||||
import icon from '../../../icons/shield.svg';
|
||||
|
||||
export class ValidatorColumn extends React.Component<Column.Props, {}> {
|
||||
public static readonly label = 'Validator';
|
||||
public static readonly icon = icon;
|
||||
public static readonly width = 16;
|
||||
public static readonly setting = 'validator';
|
||||
public static readonly sortBy = ({ validator }: Node) => validator || '';
|
||||
|
||||
private data: Maybe<string>;
|
||||
private copy: Maybe<Tooltip.CopyCallback>;
|
||||
|
||||
public shouldComponentUpdate(nextProps: Column.Props) {
|
||||
return this.data !== nextProps.node.validator;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { validator } = this.props.node;
|
||||
|
||||
this.data = validator;
|
||||
|
||||
if (!validator) {
|
||||
return <td className="Column">-</td>;
|
||||
}
|
||||
|
||||
return (
|
||||
<td className="Column" onClick={this.onClick}>
|
||||
<Tooltip text={validator} copy={this.onCopy} />
|
||||
<PolkadotIcon
|
||||
className="Column-validator"
|
||||
account={validator}
|
||||
size={16}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
private onCopy = (copy: Tooltip.CopyCallback) => {
|
||||
this.copy = copy;
|
||||
};
|
||||
|
||||
private onClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.copy != null) {
|
||||
this.copy();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './Column';
|
||||
export * from './NameColumn';
|
||||
export * from './ValidatorColumn';
|
||||
export * from './LocationColumn';
|
||||
export * from './ImplementationColumn';
|
||||
export * from './NetworkIdColumn';
|
||||
export * from './PeersColumn';
|
||||
export * from './TxsColumn';
|
||||
export * from './UploadColumn';
|
||||
export * from './DownloadColumn';
|
||||
export * from './StateCacheColumn';
|
||||
export * from './BlockNumberColumn';
|
||||
export * from './BlockHashColumn';
|
||||
export * from './FinalizedBlockColumn';
|
||||
export * from './FinalizedHashColumn';
|
||||
export * from './BlockTimeColumn';
|
||||
export * from './BlockPropagationColumn';
|
||||
export * from './LastBlockColumn';
|
||||
export * from './UptimeColumn';
|
||||
export * from './NetworkStateColumn';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<AppState>;
|
||||
appUpdate: AppUpdate;
|
||||
pins: PersistentSet<Types.NodeName>;
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
@@ -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<List.Props, {}> {
|
||||
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<Types.NodeId, Key>();
|
||||
|
||||
public componentDidMount() {
|
||||
this.onScroll();
|
||||
@@ -53,7 +58,7 @@ export class List extends React.Component<List.Props, {}> {
|
||||
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<List.Props, {}> {
|
||||
// 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 (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<div className="List" style={{ height }}>
|
||||
<table>
|
||||
<Row.HEADER columns={selectedColumns} sortBy={sortBy} />
|
||||
<tbody style={{ transform }}>
|
||||
{nodes.map((node) => (
|
||||
<table className="List--table">
|
||||
<THead columns={selectedColumns} sortBy={sortBy} />
|
||||
<tbody>
|
||||
<tr className="List-padding" style={{ height: `${top}px` }} />
|
||||
{nodes.map((node, i) => (
|
||||
<Row
|
||||
key={node.id}
|
||||
key={keys[i]}
|
||||
node={node}
|
||||
pins={pins}
|
||||
columns={selectedColumns}
|
||||
@@ -102,10 +110,63 @@ export class List extends React.Component<List.Props, {}> {
|
||||
</table>
|
||||
</div>
|
||||
<Filter onChange={this.onFilterChange} />
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Get an array of keys for each `Node` in viewport in order.
|
||||
//
|
||||
// * If a `Node` was previously rendered, it will keep its `Key`.
|
||||
//
|
||||
// * If a `Node` is new to the viewport, it will get a `Key` of
|
||||
// another `Node` that was removed from the viewport, or a new one.
|
||||
private recalculateKeys(nodes: Array<Node>): Array<Key> {
|
||||
// First we find all keys for `Node`s which didn't change from
|
||||
// last render.
|
||||
const keptKeys: Array<Maybe<Key>> = nodes.map(({ id }) => {
|
||||
const key = this.previousKeys.get(id);
|
||||
|
||||
if (key != null) {
|
||||
this.previousKeys.delete(id);
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
// Array of all unused keys
|
||||
const unusedKeys = Array.from(this.previousKeys.values());
|
||||
let search = 0;
|
||||
|
||||
// Clear the map so we can set new values
|
||||
this.previousKeys.clear();
|
||||
|
||||
// Filling in blanks and re-populate previousKeys
|
||||
return keptKeys.map((key: Maybe<Key>, i) => {
|
||||
const id = nodes[i].id;
|
||||
|
||||
// `Node` was previously in viewport
|
||||
if (key != null) {
|
||||
this.previousKeys.set(id, key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
// Recycle the next unused key
|
||||
if (search < unusedKeys.length) {
|
||||
const unused = unusedKeys[search++];
|
||||
this.previousKeys.set(id, unused);
|
||||
|
||||
return unused;
|
||||
}
|
||||
|
||||
// No unused keys left, generate a new key
|
||||
const newKey = this.nextKey++;
|
||||
this.previousKeys.set(id, newKey);
|
||||
|
||||
return newKey;
|
||||
});
|
||||
}
|
||||
|
||||
private onScroll = () => {
|
||||
const relativeTop = divisibleBy(
|
||||
window.scrollY - (HEADER + TR_HEIGHT),
|
||||
@@ -125,8 +186,10 @@ export class List extends React.Component<List.Props, {}> {
|
||||
const listStart = Math.max(((top / TR_HEIGHT) | 0) - ROW_MARGIN, 0);
|
||||
const 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({});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Row.Props, Row.State> {
|
||||
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 (
|
||||
<thead>
|
||||
<tr className="Row-Header">
|
||||
{columns.map((col, index) => (
|
||||
<HeaderCell
|
||||
key={index}
|
||||
column={col}
|
||||
index={index}
|
||||
last={last}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
public state = { update: 0 };
|
||||
|
||||
public componentDidMount() {
|
||||
const { node } = this.props;
|
||||
|
||||
node.subscribe(this.onUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
const { node } = this.props;
|
||||
|
||||
node.unsubscribe(this.onUpdate);
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(
|
||||
nextProps: Row.Props,
|
||||
nextState: Row.State
|
||||
): boolean {
|
||||
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<Row.Props, Row.State> {
|
||||
|
||||
return (
|
||||
<tr className={className} onClick={this.toggle}>
|
||||
{columns.map(({ render }, index) => (
|
||||
<td key={index}>{render(node)}</td>
|
||||
))}
|
||||
{columns.map((col, index) =>
|
||||
React.createElement(col, { node, key: index })
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +113,4 @@ export class Row extends React.Component<Row.Props, Row.State> {
|
||||
pins.add(node.name);
|
||||
}
|
||||
};
|
||||
|
||||
private onUpdate = () => {
|
||||
this.setState({ update: this.state.update + 1 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../common';
|
||||
import { Column, THeadCell } from './';
|
||||
import { Persistent } from '../../persist';
|
||||
|
||||
import './THead.css';
|
||||
|
||||
export namespace THead {
|
||||
export interface Props {
|
||||
columns: Column[];
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
}
|
||||
|
||||
export class THead extends React.Component<THead.Props, {}> {
|
||||
private sortBy: Maybe<number>;
|
||||
|
||||
constructor(props: THead.Props) {
|
||||
super(props);
|
||||
|
||||
this.sortBy = props.sortBy.get();
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: THead.Props) {
|
||||
return this.sortBy !== nextProps.sortBy.get();
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { columns, sortBy } = this.props;
|
||||
const last = columns.length - 1;
|
||||
|
||||
this.sortBy = sortBy.get();
|
||||
|
||||
return (
|
||||
<thead>
|
||||
<tr className="THead">
|
||||
{columns.map((col, index) => (
|
||||
<THeadCell
|
||||
key={index}
|
||||
column={col}
|
||||
index={index}
|
||||
last={last}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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<HeaderCell.Props, {}> {
|
||||
export class THeadCell extends React.Component<THeadCell.Props, {}> {
|
||||
public render() {
|
||||
const { column, index, last } = this.props;
|
||||
const { icon, width, label } = column;
|
||||
@@ -25,10 +25,10 @@ export class HeaderCell extends React.Component<HeaderCell.Props, {}> {
|
||||
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<HeaderCell.Props, {}> {
|
||||
style={width ? { width } : undefined}
|
||||
onClick={this.toggleSort}
|
||||
>
|
||||
<Tooltip text={label} inline={true} position={position}>
|
||||
<span className="THeadCell-container">
|
||||
<Tooltip text={label} position={position} />
|
||||
<Icon src={i} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Tooltip } from '../';
|
||||
|
||||
export namespace Truncate {
|
||||
export interface Props {
|
||||
text: string;
|
||||
copy?: boolean;
|
||||
position?: Tooltip.Props['position'];
|
||||
}
|
||||
}
|
||||
|
||||
export class Truncate extends React.Component<Truncate.Props, {}> {
|
||||
public render() {
|
||||
const { text, position, copy } = this.props;
|
||||
|
||||
if (!text) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={text}
|
||||
position={position}
|
||||
copy={copy}
|
||||
className="Row-Tooltip"
|
||||
>
|
||||
<div className="Row-truncate">{text}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Truncate.Props): boolean {
|
||||
return (
|
||||
this.props.text !== nextProps.text ||
|
||||
this.props.position !== nextProps.position
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -97,7 +97,7 @@ export class Location extends React.Component<Location.Props, Location.State> {
|
||||
validatorRow = (
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeValidatorIcon} alt="Node" />
|
||||
<Icon src={nodeValidatorIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>
|
||||
{trimHash(validator, 30)}
|
||||
@@ -113,53 +113,53 @@ export class Location extends React.Component<Location.Props, Location.State> {
|
||||
<table className="Location-details Location-details">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeIcon} alt="Node" />
|
||||
<td title="Node">
|
||||
<Icon src={nodeIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>{name}</td>
|
||||
</tr>
|
||||
{validatorRow}
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeTypeIcon} alt="Implementation" />
|
||||
<td title="Implementation">
|
||||
<Icon src={nodeTypeIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>
|
||||
{implementation} v{version}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeLocationIcon} alt="Location" />
|
||||
<td title="Location">
|
||||
<Icon src={nodeLocationIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>{city}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockIcon} alt="Block" />
|
||||
<td title="Block">
|
||||
<Icon src={blockIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>#{formatNumber(height)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockHashIcon} alt="Block Hash" />
|
||||
<td title="Block Hash">
|
||||
<Icon src={blockHashIcon} />
|
||||
</td>
|
||||
<td colSpan={5}>{trimHash(hash, 20)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockTimeIcon} alt="Block Time" />
|
||||
<td title="Block Time">
|
||||
<Icon src={blockTimeIcon} />
|
||||
</td>
|
||||
<td style={{ width: 80 }}>
|
||||
{secondsWithPrecision(blockTime / 1000)}
|
||||
</td>
|
||||
<td>
|
||||
<Icon src={propagationTimeIcon} alt="Block Propagation Time" />
|
||||
<td title="Block Propagation Time">
|
||||
<Icon src={propagationTimeIcon} />
|
||||
</td>
|
||||
<td style={{ width: 58 }}>
|
||||
{propagationTime == null ? '∞' : milliOrSecond(propagationTime)}
|
||||
</td>
|
||||
<td>
|
||||
<Icon src={lastTimeIcon} alt="Last Block Time" />
|
||||
<td title="Last Block Time">
|
||||
<Icon src={lastTimeIcon} />
|
||||
</td>
|
||||
<td style={{ minWidth: 82 }}>
|
||||
<Ago when={blockTimestamp} />
|
||||
|
||||
@@ -34,7 +34,7 @@ export class Map extends React.Component<Map.Props, Map.State> {
|
||||
left: 0,
|
||||
};
|
||||
|
||||
public componentWillMount() {
|
||||
public componentDidMount() {
|
||||
this.onResize();
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
@@ -19,14 +19,17 @@ export function OfflineIndicator(
|
||||
return null;
|
||||
case 'offline':
|
||||
return (
|
||||
<div className="OfflineIndicator">
|
||||
<Icon src={offlineIcon} alt="Offline" />
|
||||
<div className="OfflineIndicator" title="Offline">
|
||||
<Icon src={offlineIcon} />
|
||||
</div>
|
||||
);
|
||||
case 'upgrade-requested':
|
||||
return (
|
||||
<div className="OfflineIndicator OfflineIndicator-upgrade">
|
||||
<Icon src={upgradeIcon} alt="New Version Available" />
|
||||
<div
|
||||
className="OfflineIndicator OfflineIndicator-upgrade"
|
||||
title="New Version Available"
|
||||
>
|
||||
<Icon src={upgradeIcon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
// Lazily render the icon in the DOM, so that we can referenced
|
||||
// it by id using shadow DOM.
|
||||
function renderShadowIcon(account: string) {
|
||||
if (!rendered.has(account)) {
|
||||
rendered.add(account);
|
||||
|
||||
const symEl = document.createElementNS(W3SVG, 'symbol');
|
||||
|
||||
symEl.setAttribute('id', account);
|
||||
symEl.setAttribute('viewBox', '0 0 64 64');
|
||||
|
||||
generate(account, false).forEach(({ cx, cy, r, fill }) => {
|
||||
const circle = document.createElementNS(W3SVG, 'circle');
|
||||
|
||||
circle.setAttribute('cx', String(cx));
|
||||
circle.setAttribute('cy', String(cy));
|
||||
circle.setAttribute('r', String(r));
|
||||
circle.setAttribute('fill', fill);
|
||||
|
||||
symEl.appendChild(circle);
|
||||
});
|
||||
|
||||
getSVGShadowRoot().appendChild(symEl);
|
||||
}
|
||||
}
|
||||
|
||||
export class PolkadotIcon extends React.Component<PolkadotIcon.Props, {}> {
|
||||
public render(): React.ReactNode {
|
||||
const { account, size } = this.props;
|
||||
|
||||
public shouldComponentUpdate(nextProps: PolkadotIcon.Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64">
|
||||
{generate(account, false).map(this.renderCircle)}
|
||||
</svg>
|
||||
this.props.account !== nextProps.account ||
|
||||
this.props.size !== nextProps.size
|
||||
);
|
||||
}
|
||||
|
||||
private renderCircle = (
|
||||
{ cx, cy, r, fill }: Circle,
|
||||
key: number
|
||||
): React.ReactNode => {
|
||||
return <circle key={key} cx={cx} cy={cy} r={r} fill={fill} />;
|
||||
};
|
||||
public render(): React.ReactNode {
|
||||
const { account, size, className } = this.props;
|
||||
renderShadowIcon(account);
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={className}>
|
||||
<use href={`#${account}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class Setting extends React.Component<Setting.Props, {}> {
|
||||
|
||||
return (
|
||||
<div className={className} onClick={this.toggle}>
|
||||
<Icon src={icon} alt={label} />
|
||||
<Icon src={icon} />
|
||||
{label}
|
||||
<span className="Setting-switch">
|
||||
<span className="Setting-knob" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Sparkline.Props, {}> {
|
||||
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 (
|
||||
<Tooltip text="-" onInit={this.onTooltipInit}>
|
||||
<svg
|
||||
className="Sparkline"
|
||||
ref={this.onRef}
|
||||
width={width}
|
||||
height={height}
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Tooltip text="-" onInit={this.onTooltipInit} />
|
||||
<svg
|
||||
className="Sparkline"
|
||||
width={width}
|
||||
height={height}
|
||||
strokeWidth={stroke}
|
||||
onMouseMove={this.onMouseMove}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<path d={`M 0 ${height} L ${path} V ${height} Z`} stroke="none" />
|
||||
<path d={`M ${path}`} fill="none" />
|
||||
<path className="Sparkline-cursor" strokeWidth="2" ref={this.onRef} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private onRef = (cursor: SVGPathElement) => {
|
||||
this.cursor = cursor;
|
||||
};
|
||||
|
||||
private onTooltipInit = (update: Tooltip.UpdateCallback) => {
|
||||
@@ -79,13 +82,22 @@ export class Sparkline extends React.Component<Sparkline.Props, {}> {
|
||||
};
|
||||
|
||||
private onMouseMove = (
|
||||
_event: MouseEvent,
|
||||
data: { value: number; index: number }
|
||||
event: React.MouseEvent<SVGSVGElement, 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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export namespace Tile {
|
||||
export function Tile(props: Tile.Props) {
|
||||
return (
|
||||
<div className="Tile">
|
||||
<Icon src={props.icon} alt={props.title} />
|
||||
<Icon src={props.icon} />
|
||||
<span className="Tile-label">{props.title}</span>
|
||||
<span className="Tile-content">{props.children}</span>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Tooltip.Props, Tooltip.State> {
|
||||
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<Tooltip.Props, Tooltip.State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClass} onClick={this.onClick}>
|
||||
<div className={tooltipClass} ref={this.onRef}>
|
||||
{copied ? 'Copied to clipboard!' : text}
|
||||
</div>
|
||||
{this.props.children}
|
||||
<div className={tooltipClass} ref={this.onRef}>
|
||||
{copied ? 'Copied to clipboard!' : text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,16 +92,12 @@ export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
|
||||
this.el.textContent = text;
|
||||
};
|
||||
|
||||
private onClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
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<Tooltip.Props, Tooltip.State> {
|
||||
|
||||
private restore = () => {
|
||||
this.setState({ copied: false });
|
||||
this.timer = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export namespace Truncate {
|
||||
export interface Props {
|
||||
text: string;
|
||||
chars?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export class Truncate extends React.Component<Truncate.Props, {}> {
|
||||
public shouldComponentUpdate(nextProps: Truncate.Props): boolean {
|
||||
return this.props.text !== nextProps.text;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { text, chars } = this.props;
|
||||
|
||||
if (!text) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (chars != null && text.length <= chars) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return chars ? (
|
||||
`${text.substr(0, chars - 1)}…`
|
||||
) : (
|
||||
<div className="Column-truncate">{text}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user