mirror of
https://github.com/pezkuwichain/pezkuwi-telemetry.git
synced 2026-04-30 17:48:00 +00:00
Restructure the js app (#243)
* prettier * linter * add prettier, and format the code * remove common, merge it with frontend * refactor the app * better lint and code fix * travis for the frontend app * travis build script Signed-off-by: Daniel Maricic <daniel@woss.io> * lint and build * update the README.md Signed-off-by: Daniel Maricic <daniel@woss.io> * change the commands to reflect refactor Signed-off-by: Daniel Maricic <daniel@woss.io> * prettier and tslint are friends Signed-off-by: Daniel Maricic <daniel@woss.io> * code that wasn't linted properly before Signed-off-by: Daniel Maricic <daniel@woss.io> * prettier rc got deleted * workgin on making the travis pass Signed-off-by: Daniel Maricic <daniel@woss.io> * travis build please? Signed-off-by: Daniel Maricic <daniel@woss.io> * update readme.md Signed-off-by: Daniel Maricic <daniel@woss.io> * dockerfile deleted from fronted - out of scope Signed-off-by: Daniel Maricic <daniel@woss.io> * remove Signed-off-by: Daniel Maricic <daniel@woss.io> * tsconfig Signed-off-by: Daniel Maricic <daniel@woss.io> * found the reason why EOL wasn't happening Signed-off-by: Daniel Maricic <daniel@woss.io> * type for the event in the ConnectionInput as suggested * strictnullCheck to true * noImplicitAny * noUnusedParams * AfgHandling * update * fix Location.tsx * Few minor fixes * remove connection input and revert to original * esnext fixes the imports for icons and non default `* as ` * update to the tsconfig.test.json don't use commonjs please * fixed wrong comment for TIMEOUT_BASE * return totem.svg and type decraration of maybe Signed-off-by: Daniel Maricic <daniel@woss.io> Co-authored-by: Will <w.kopp@kigroup.de>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import './Tile.css';
|
||||
import { timestamp, Types } from '../common';
|
||||
|
||||
export namespace Ago {
|
||||
export interface Props {
|
||||
when: Types.Timestamp;
|
||||
justTime?: boolean;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
now: Types.Timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
const tickers = new Map<Ago, (ts: Types.Timestamp) => void>();
|
||||
|
||||
function tick() {
|
||||
const now = timestamp();
|
||||
|
||||
for (const ticker of tickers.values()) {
|
||||
ticker(now);
|
||||
}
|
||||
|
||||
setTimeout(tick, 100);
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
export namespace Ago {
|
||||
export interface State {
|
||||
now: Types.Timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
export class Ago extends React.Component<Ago.Props, Ago.State> {
|
||||
public static timeDiff = 0 as Types.Milliseconds;
|
||||
|
||||
public state: Ago.State;
|
||||
|
||||
constructor(props: Ago.Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
now: (timestamp() - Ago.timeDiff) as Types.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
public componentWillMount() {
|
||||
tickers.set(this, (now) => {
|
||||
this.setState({
|
||||
now: (now - Ago.timeDiff) as Types.Timestamp,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
tickers.delete(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.props.when === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const ago = Math.max(this.state.now - this.props.when, 0) / 1000;
|
||||
|
||||
let agoStr: string;
|
||||
|
||||
if (ago < 10) {
|
||||
agoStr = `${ago.toFixed(1)}s`;
|
||||
} else if (ago < 60) {
|
||||
agoStr = `${ago | 0}s`;
|
||||
} else if (ago < 3600) {
|
||||
agoStr = `${(ago / 60) | 0}m`;
|
||||
} else if (ago < 3600 * 24) {
|
||||
agoStr = `${(ago / 3600) | 0}h`;
|
||||
} else {
|
||||
agoStr = `${(ago / (3600 * 24)) | 0}d`;
|
||||
}
|
||||
|
||||
if (this.props.justTime !== true) {
|
||||
agoStr += ' ago';
|
||||
}
|
||||
|
||||
return (
|
||||
<span title={new Date(this.props.when).toUTCString()}>{agoStr}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
.AllChains {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
margin: 0 0 0 -150px;
|
||||
width: 25vw;
|
||||
min-width: 300px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.35);
|
||||
overflow-y: scroll;
|
||||
overflow-x: hide;
|
||||
}
|
||||
|
||||
.AllChains-overlay {
|
||||
position: fixed;
|
||||
display: block;
|
||||
z-index: 19;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.AllChains-chain {
|
||||
padding: 0 12px;
|
||||
background: #b5aeae;
|
||||
color: #444;
|
||||
display: block;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.5);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.AllChains-node-count {
|
||||
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;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4em;
|
||||
margin: 0 -0.3em 0 0.3em;
|
||||
}
|
||||
|
||||
.AllChains-chain-selected {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.AllChains-chain-selected .AllChains-node-count {
|
||||
background: #e6007a;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { Connection } from '../Connection';
|
||||
import { Types, Maybe } from '../common';
|
||||
import { ChainData } from '../state';
|
||||
|
||||
import './AllChains.css';
|
||||
|
||||
export namespace AllChains {
|
||||
export interface Props {
|
||||
chains: ChainData[];
|
||||
subscribed: Maybe<Types.ChainLabel>;
|
||||
connection: Promise<Connection>;
|
||||
}
|
||||
}
|
||||
|
||||
export class AllChains extends React.Component<AllChains.Props, {}> {
|
||||
public render() {
|
||||
const { chains, subscribed } = this.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>
|
||||
);
|
||||
}
|
||||
|
||||
private renderChain(chain: ChainData): React.ReactNode {
|
||||
const { label, nodeCount } = chain;
|
||||
|
||||
const className =
|
||||
label === this.props.subscribed
|
||||
? 'AllChains-chain AllChains-chain-selected'
|
||||
: 'AllChains-chain';
|
||||
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
className={className}
|
||||
onClick={this.subscribe.bind(this, label)}
|
||||
>
|
||||
{label}{' '}
|
||||
<span className="AllChains-node-count" title="Node Count">
|
||||
{nodeCount}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
private async subscribe(chain: Types.ChainLabel) {
|
||||
const connection = await this.props.connection;
|
||||
|
||||
connection.subscribe(chain);
|
||||
connection.resetConsensus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 148px;
|
||||
}
|
||||
|
||||
.Chain-content {
|
||||
width: 100%;
|
||||
min-width: 1350px;
|
||||
min-height: 100%;
|
||||
background: #2c2b2b;
|
||||
color: #fff;
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0 3px 30px;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { 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 {
|
||||
export type Display = 'list' | 'map' | 'settings' | 'consensus';
|
||||
|
||||
export interface Props {
|
||||
appState: Readonly<AppState>;
|
||||
connection: Promise<Connection>;
|
||||
settings: PersistentObject<AppState.Settings>;
|
||||
pins: PersistentSet<Types.NodeName>;
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
display: Display;
|
||||
}
|
||||
}
|
||||
|
||||
export class Chain extends React.Component<Chain.Props, Chain.State> {
|
||||
constructor(props: Chain.Props) {
|
||||
super(props);
|
||||
|
||||
let display: Chain.Display = 'list';
|
||||
|
||||
switch (getHashData().tab) {
|
||||
case 'map':
|
||||
display = 'map';
|
||||
break;
|
||||
case 'settings':
|
||||
display = 'settings';
|
||||
break;
|
||||
case 'consensus':
|
||||
display = 'consensus';
|
||||
break;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
display,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { appState } = this.props;
|
||||
const { best, finalized, blockTimestamp, blockAverage } = appState;
|
||||
const { display: currentTab } = this.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>
|
||||
<div className="Chain-content-container">
|
||||
<div className="Chain-content">{this.renderContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
const { display } = this.state;
|
||||
|
||||
if (display === 'settings') {
|
||||
return <Settings settings={this.props.settings} />;
|
||||
}
|
||||
|
||||
const { appState, connection, pins, sortBy } = this.props;
|
||||
|
||||
if (display === 'consensus') {
|
||||
return <Consensus appState={appState} connection={connection} />;
|
||||
}
|
||||
|
||||
return display === 'list' ? (
|
||||
<List appState={appState} pins={pins} sortBy={sortBy} />
|
||||
) : (
|
||||
<Map appState={appState} />
|
||||
);
|
||||
}
|
||||
|
||||
private setDisplay = (display: Chain.Display) => {
|
||||
this.setState({ display });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.Chain-Tab {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Chain-Tab .Icon {
|
||||
margin-right: 5px;
|
||||
font-size: 24px;
|
||||
padding: 6px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 40px;
|
||||
transition: background-color 0.15s linear;
|
||||
}
|
||||
|
||||
.Chain-Tab:hover .Icon {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.Chain-Tab-on .Icon,
|
||||
.Chain-Tab-on:hover .Icon {
|
||||
background: #e6007a;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import { Chain } from './';
|
||||
import { Icon } from '../';
|
||||
import { setHashData } from '../../utils';
|
||||
|
||||
import './Tab.css';
|
||||
|
||||
export namespace Tab {
|
||||
export interface Props {
|
||||
label: string;
|
||||
icon: string;
|
||||
display: Chain.Display;
|
||||
current: string;
|
||||
tab: string;
|
||||
setDisplay: (display: Chain.Display) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export class Tab extends React.Component<Tab.Props, {}> {
|
||||
public render() {
|
||||
const { label, icon, display, current } = this.props;
|
||||
const highlight = display === current;
|
||||
const className = highlight ? 'Chain-Tab-on Chain-Tab' : 'Chain-Tab';
|
||||
|
||||
return (
|
||||
<div className={className} onClick={this.onClick}>
|
||||
<Icon src={icon} alt={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onClick = () => {
|
||||
const { tab, display, setDisplay } = this.props;
|
||||
setHashData({ tab });
|
||||
setDisplay(display);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Chain';
|
||||
export * from './Tab';
|
||||
@@ -0,0 +1,75 @@
|
||||
.Chains {
|
||||
background: #b5aeae;
|
||||
color: #000;
|
||||
padding: 0 76px 0 16px;
|
||||
height: 40px;
|
||||
min-width: 1318px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Chains-chain {
|
||||
padding: 0 12px;
|
||||
background: #b5aeae;
|
||||
color: #444;
|
||||
display: inline-block;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.5);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
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);
|
||||
}
|
||||
|
||||
.Chains-all-chains {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 48px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.Chains-fork-me {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
}
|
||||
|
||||
.Chains-all-chains .Icon,
|
||||
.Chains-fork-me .Icon {
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
color: #3c3c3b;
|
||||
}
|
||||
|
||||
.Chains-node-count {
|
||||
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;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4em;
|
||||
margin: 0 -0.3em 0 0.3em;
|
||||
}
|
||||
|
||||
.Chains-chain-selected {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.Chains-chain-selected .Chains-node-count {
|
||||
background: #e6007a;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { Connection } from '../Connection';
|
||||
import { Icon } from './Icon';
|
||||
import { Types, Maybe } from '../common';
|
||||
import { ChainData } from '../state';
|
||||
|
||||
import githubIcon from '../icons/mark-github.svg';
|
||||
import listIcon from '../icons/three-bars.svg';
|
||||
import './Chains.css';
|
||||
|
||||
export namespace Chains {
|
||||
export interface Props {
|
||||
chains: ChainData[];
|
||||
subscribed: Maybe<Types.ChainLabel>;
|
||||
connection: Promise<Connection>;
|
||||
}
|
||||
}
|
||||
|
||||
export class Chains extends React.Component<Chains.Props, {}> {
|
||||
public render() {
|
||||
const allChainsHref = this.props.subscribed
|
||||
? `#all-chains/${this.props.subscribed}`
|
||||
: `#all-chains`;
|
||||
const { chains } = this.props;
|
||||
|
||||
return (
|
||||
<div className="Chains">
|
||||
{chains.map((chain) => this.renderChain(chain))}
|
||||
<a className="Chains-all-chains" href={allChainsHref}>
|
||||
<Icon src={listIcon} alt="All Chains" />
|
||||
</a>
|
||||
<a
|
||||
className="Chains-fork-me"
|
||||
href="https://github.com/paritytech/substrate-telemetry"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon src={githubIcon} alt="Fork Me!" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderChain(chain: ChainData): React.ReactNode {
|
||||
const { label, nodeCount } = chain;
|
||||
|
||||
const className =
|
||||
label === this.props.subscribed
|
||||
? 'Chains-chain Chains-chain-selected'
|
||||
: 'Chains-chain';
|
||||
|
||||
return (
|
||||
<a
|
||||
key={label}
|
||||
className={className}
|
||||
onClick={this.subscribe.bind(this, label)}
|
||||
>
|
||||
{label}{' '}
|
||||
<span className="Chains-node-count" title="Node Count">
|
||||
{nodeCount}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
private async subscribe(chain: Types.ChainLabel) {
|
||||
const connection = await this.props.connection;
|
||||
|
||||
connection.subscribe(chain);
|
||||
connection.resetConsensus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
.Consensus .ConsensusList {
|
||||
opacity: 0; /* the box should only show up once flexing has been applied */
|
||||
}
|
||||
|
||||
.Consensus .ConsensusList table {
|
||||
border-spacing: 0px;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .firstInRow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .firstInRow .emptylegend,
|
||||
.Consensus .flexContainerLargeRow .firstInRow .nameLegend {
|
||||
width: 99%;
|
||||
flex-grow: 1000000000;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerSmallRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerSmallRow div {
|
||||
align-self: stretch;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerSmallRow table .legend {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Consensus .ConsensusList {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.Consensus {
|
||||
width: 100%;
|
||||
min-width: 1350px;
|
||||
min-height: 100%;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow {
|
||||
float: left;
|
||||
clear: both;
|
||||
font-size: 8px !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.Consensus .hatching svg {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .hatching svg {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
}
|
||||
|
||||
.Consensus .matrixXLegend .Tooltip-container {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.Consensus .legend {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.Consensus .nameLegend {
|
||||
border-right: none;
|
||||
border-bottom: 1px dotted #555;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .nameLegend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .finalizedInfo .Tooltip-container {
|
||||
float: none;
|
||||
display: inline-block !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .finalizedInfo {
|
||||
min-height: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .explicit,
|
||||
.Consensus .SmallRow .implicit {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .finalizedInfo .explicit,
|
||||
.Consensus .SmallRow .finalizedInfo .implicit {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.Consensus .nodeAddress {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.Consensus .first_false .legend .nodeAddress,
|
||||
.Consensus .SmallRow .legend .nodeAddress,
|
||||
.Consensus th.finalizedInfo .Tooltip-container {
|
||||
float: none !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.Consensus .noStretchOnLastRow::after {
|
||||
content: '';
|
||||
flex-grow: 1000000000;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow table {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow .emptylegend {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* similar to .App-no-telemetry */
|
||||
.Consensus .noData {
|
||||
width: 100vw;
|
||||
line-height: 60vh;
|
||||
font-size: 56px;
|
||||
font-weight: 100;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* similar to .App-no-telemetry */
|
||||
.Consensus .tooManyAuthorities {
|
||||
width: 100vw;
|
||||
line-height: 20vh;
|
||||
font-size: 56px;
|
||||
font-weight: 100;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.Consensus svg {
|
||||
z-index: 999999999;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../../common';
|
||||
import { Connection } from '../../Connection';
|
||||
import Measure, { BoundingRect, ContentRect } from 'react-measure';
|
||||
|
||||
import { ConsensusBlock } from './';
|
||||
import { State as AppState } from '../../state';
|
||||
|
||||
import './Consensus.css';
|
||||
|
||||
// Maximum number of authorities the visualization is
|
||||
// allowed of processing.
|
||||
export const VIS_AUTHORITIES_LIMIT = 10;
|
||||
|
||||
export namespace Consensus {
|
||||
export interface Props {
|
||||
appState: Readonly<AppState>;
|
||||
connection: Promise<Connection>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
dimensions: BoundingRect;
|
||||
|
||||
largeBlockWithLegend: BoundingRect;
|
||||
largeBlock: BoundingRect;
|
||||
countBlocksInLargeRow: number;
|
||||
largeRowsAddFlexClass: boolean;
|
||||
|
||||
smallBlock: BoundingRect;
|
||||
smallBlocksRows: number;
|
||||
countBlocksInSmallRow: number;
|
||||
smallRowsAddFlexClass: boolean;
|
||||
lastConsensusInfo: string;
|
||||
}
|
||||
}
|
||||
|
||||
export class Consensus extends React.Component<Consensus.Props, {}> {
|
||||
public state = {
|
||||
// entire area available for rendering the visualization
|
||||
dimensions: { width: -1, height: -1 } as BoundingRect,
|
||||
|
||||
largeBlockWithLegend: { width: -1, height: -1 } as BoundingRect,
|
||||
largeBlock: { width: -1, height: -1 } as BoundingRect,
|
||||
countBlocksInLargeRow: 2,
|
||||
largeRowsAddFlexClass: false,
|
||||
|
||||
smallBlock: { width: -1, height: -1 } as BoundingRect,
|
||||
smallBlocksRows: 1,
|
||||
countBlocksInSmallRow: 1,
|
||||
smallRowsAddFlexClass: false,
|
||||
lastConsensusInfo: '',
|
||||
};
|
||||
|
||||
public shouldComponentUpdate(
|
||||
nextProps: Consensus.Props,
|
||||
nextState: Consensus.State
|
||||
): boolean {
|
||||
if (
|
||||
this.props.appState.authorities.length === 0 &&
|
||||
nextProps.appState.authorities.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.calculateBoxCount(false);
|
||||
|
||||
// size detected, but flex class has not yet been added
|
||||
const largeBlocksSizeDetected =
|
||||
this.largeBlocksSizeDetected(nextState) === true &&
|
||||
this.state.largeRowsAddFlexClass === false;
|
||||
if (largeBlocksSizeDetected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const smallBlocksSizeDetected =
|
||||
this.smallBlocksSizeDetected(nextState) === true &&
|
||||
this.state.smallRowsAddFlexClass === false;
|
||||
if (smallBlocksSizeDetected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const windowSizeChanged =
|
||||
JSON.stringify(this.state.dimensions) !==
|
||||
JSON.stringify(nextState.dimensions);
|
||||
if (windowSizeChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newConsensusInfoAvailable =
|
||||
this.state.lastConsensusInfo !==
|
||||
JSON.stringify(nextProps.appState.consensusInfo);
|
||||
if (newConsensusInfoAvailable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authoritySetIdDidChange =
|
||||
this.props.appState.authoritySetId !== nextProps.appState.authoritySetId;
|
||||
if (authoritySetIdDidChange) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authoritiesDidChange =
|
||||
JSON.stringify(this.props.appState.authorities) !==
|
||||
JSON.stringify(nextProps.appState.authorities);
|
||||
if (authoritiesDidChange) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.props.appState.subscribed != null) {
|
||||
const chain = this.props.appState.subscribed;
|
||||
this.subscribeConsensus(chain);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.appState.subscribed != null) {
|
||||
const chain = this.props.appState.subscribed;
|
||||
this.unsubscribeConsensus(chain);
|
||||
}
|
||||
}
|
||||
|
||||
public largeBlocksSizeDetected(state: Consensus.State): boolean {
|
||||
// we can only state that we detected the two block sizes (with
|
||||
// legend and without) if at least two blocks have been added:
|
||||
// the first displayed block will always have a legend with the
|
||||
// node names attached, the second not.
|
||||
if (this.props.appState.consensusInfo.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if there is more than one block then the size of the first block (with legend)
|
||||
// will be different from the succeeding blocks (without legend)
|
||||
return (
|
||||
state.largeBlockWithLegend.width > -1 &&
|
||||
state.largeBlockWithLegend.height > -1 &&
|
||||
state.largeBlock.width > -1 &&
|
||||
state.largeBlock.height > -1
|
||||
);
|
||||
}
|
||||
|
||||
public smallBlocksSizeDetected(state: Consensus.State): boolean {
|
||||
return (
|
||||
state.smallBlock.width > -1 && state.largeBlockWithLegend.height > -1
|
||||
);
|
||||
}
|
||||
|
||||
public calculateBoxCount(wasResized: boolean) {
|
||||
// if the css class for flexing has already been added we don't calculate
|
||||
// any box measurements then, because the box sizes would be skewed then.
|
||||
if (
|
||||
(wasResized || this.state.largeRowsAddFlexClass === false) &&
|
||||
this.largeBlocksSizeDetected(this.state)
|
||||
) {
|
||||
// we need to add +2 because of the last block which doesn't contain a border.
|
||||
let countBlocks =
|
||||
(this.state.dimensions.width -
|
||||
this.state.largeBlockWithLegend.width +
|
||||
2) /
|
||||
(this.state.largeBlock.width + 2);
|
||||
|
||||
// +1 because the firstRect was subtracted above and needs to be counted back in.
|
||||
// default count is 2 because we need two blocks to measure properly (one with legend
|
||||
// and one without. these measures are necessary to calculate the number of blocks
|
||||
// which fit.
|
||||
countBlocks =
|
||||
Math.floor(countBlocks + 1) < 1 ? 2 : Math.floor(countBlocks + 1);
|
||||
|
||||
this.setState({
|
||||
largeRowsAddFlexClass: true,
|
||||
countBlocksInLargeRow: countBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(wasResized || this.state.smallRowsAddFlexClass === false) &&
|
||||
this.smallBlocksSizeDetected(this.state)
|
||||
) {
|
||||
const howManyRows = 2;
|
||||
|
||||
const heightLeft =
|
||||
this.state.dimensions.height -
|
||||
this.state.largeBlock.height * howManyRows;
|
||||
|
||||
let smallBlocksRows = heightLeft / this.state.smallBlock.height;
|
||||
smallBlocksRows = smallBlocksRows < 1 ? 1 : Math.floor(smallBlocksRows);
|
||||
|
||||
let countBlocksInSmallRow =
|
||||
this.state.dimensions.width / this.state.smallBlock.width;
|
||||
countBlocksInSmallRow =
|
||||
countBlocksInSmallRow < 1 ? 1 : Math.floor(countBlocksInSmallRow);
|
||||
|
||||
this.setState({
|
||||
smallRowsAddFlexClass: true,
|
||||
countBlocksInSmallRow,
|
||||
smallBlocksRows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
this.state.lastConsensusInfo = JSON.stringify(
|
||||
this.props.appState.consensusInfo
|
||||
);
|
||||
const lastBlocks = this.props.appState.consensusInfo;
|
||||
|
||||
if (this.props.appState.authorities.length > VIS_AUTHORITIES_LIMIT) {
|
||||
return (
|
||||
<div className="Consensus">
|
||||
<div className="tooManyAuthorities">
|
||||
<p>Too many authorities.</p>
|
||||
<p>
|
||||
Won't display for more than {VIS_AUTHORITIES_LIMIT} authorities to
|
||||
protect your browser.
|
||||
</p>
|
||||
</div>
|
||||
;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.props.appState.displayConsensusLoadingScreen &&
|
||||
lastBlocks.length < 2
|
||||
) {
|
||||
return (
|
||||
<div className="Consensus">
|
||||
<div className="noData">
|
||||
{lastBlocks.length === 0 ? 'No ' : 'Not yet enough '}
|
||||
GRANDPA data received by the authorities…
|
||||
</div>
|
||||
;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let from = 0;
|
||||
let to = this.state.countBlocksInLargeRow;
|
||||
const firstLargeRow = this.getLargeRow(lastBlocks.slice(from, to), 0);
|
||||
|
||||
from = to;
|
||||
to = to + this.state.countBlocksInLargeRow;
|
||||
const secondLargeRow = this.getLargeRow(lastBlocks.slice(from, to), 1);
|
||||
|
||||
from = to;
|
||||
to = to + this.state.smallBlocksRows * this.state.countBlocksInSmallRow;
|
||||
const smallRow = this.getSmallRow(lastBlocks.slice(from, to));
|
||||
|
||||
const get = (measureRef: Maybe<(ref: Element | null) => void>) => (
|
||||
<div className="Consensus" ref={measureRef} key="Consensus">
|
||||
{firstLargeRow}
|
||||
{secondLargeRow}
|
||||
{smallRow}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (
|
||||
!(this.state.smallRowsAddFlexClass && this.state.largeRowsAddFlexClass)
|
||||
) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Measure bounds={true} onResize={this.handleOnResize}>
|
||||
{({ measureRef }) => get(measureRef)}
|
||||
</Measure>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
return get(null);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOnResize = (contentRect: ContentRect) => {
|
||||
this.setState({ dimensions: contentRect.bounds as BoundingRect });
|
||||
this.calculateBoxCount(true);
|
||||
};
|
||||
|
||||
private getAuthorities(): Types.Authority[] {
|
||||
// find the node for each of these authority addresses
|
||||
if (this.props.appState.authorities == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.props.appState.authorities.map((address) => {
|
||||
const node2 = this.props.appState.nodes
|
||||
.sorted()
|
||||
.filter((node) => node.validator === address)[0];
|
||||
if (!node2) {
|
||||
return {
|
||||
Address: address,
|
||||
NodeId: null,
|
||||
Name: null,
|
||||
} as Types.Authority;
|
||||
}
|
||||
return {
|
||||
Address: address,
|
||||
NodeId: node2.id,
|
||||
Name: node2.name,
|
||||
} as Types.Authority;
|
||||
});
|
||||
}
|
||||
|
||||
private getLargeRow(blocks: Types.ConsensusInfo, id: number) {
|
||||
const largeBlockSizeChanged = (
|
||||
isFirstBlock: boolean,
|
||||
rect: BoundingRect
|
||||
) => {
|
||||
if (this.largeBlocksSizeDetected(this.state)) {
|
||||
return;
|
||||
}
|
||||
if (isFirstBlock) {
|
||||
this.setState({
|
||||
largeBlockWithLegend: { width: rect.width, height: rect.height },
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
largeBlock: { width: rect.width, height: rect.height },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stretchLastRowMajor =
|
||||
blocks.length < this.state.countBlocksInLargeRow
|
||||
? 'noStretchOnLastRow'
|
||||
: '';
|
||||
const flexClass = this.state.largeRowsAddFlexClass
|
||||
? 'flexContainerLargeRow'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ConsensusList LargeRow ${flexClass} ${stretchLastRowMajor}`}
|
||||
key={`consensusList_${id}`}
|
||||
>
|
||||
{blocks.map((item, i) => {
|
||||
const [height, consensusView] = item;
|
||||
return (
|
||||
<ConsensusBlock
|
||||
changeBlocks={largeBlockSizeChanged}
|
||||
firstInRow={i === 0}
|
||||
lastInRow={false}
|
||||
compact={false}
|
||||
key={height}
|
||||
height={height}
|
||||
measure={!this.state.largeRowsAddFlexClass}
|
||||
consensusView={consensusView}
|
||||
authorities={this.getAuthorities()}
|
||||
authoritySetId={this.props.appState.authoritySetId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getSmallRow(blocks: Types.ConsensusInfo) {
|
||||
const smallBlockSizeChanged = (
|
||||
_isFirstBlock: boolean,
|
||||
rect: BoundingRect
|
||||
) => {
|
||||
if (this.smallBlocksSizeDetected(this.state)) {
|
||||
return;
|
||||
}
|
||||
const dimensionsChanged =
|
||||
this.state.smallBlock.height !== rect.height &&
|
||||
this.state.smallBlock.width !== rect.width;
|
||||
if (dimensionsChanged) {
|
||||
this.setState({
|
||||
smallBlock: { width: rect.width, height: rect.height },
|
||||
});
|
||||
}
|
||||
};
|
||||
const stretchLastRow =
|
||||
blocks.length <
|
||||
this.state.countBlocksInSmallRow * this.state.smallBlocksRows
|
||||
? 'noStretchOnLastRow'
|
||||
: '';
|
||||
const classes = `ConsensusList SmallRow ${
|
||||
this.state.smallRowsAddFlexClass ? 'flexContainerSmallRow' : ''
|
||||
} ${stretchLastRow}`;
|
||||
|
||||
return (
|
||||
<div className={classes} key="smallRow">
|
||||
{blocks.map((item, i) => {
|
||||
const [height, consensusView] = item;
|
||||
let lastInRow =
|
||||
(i + 1) % this.state.countBlocksInSmallRow === 0 ? true : false;
|
||||
if (lastInRow && i === 0) {
|
||||
// should not be marked as last one in row if it's the very first in row
|
||||
lastInRow = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConsensusBlock
|
||||
changeBlocks={smallBlockSizeChanged}
|
||||
firstInRow={i === 0}
|
||||
lastInRow={lastInRow}
|
||||
compact={true}
|
||||
key={height}
|
||||
height={height}
|
||||
measure={!this.state.smallRowsAddFlexClass}
|
||||
consensusView={consensusView}
|
||||
authorities={this.getAuthorities()}
|
||||
authoritySetId={this.props.appState.authoritySetId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private async subscribeConsensus(chain: Types.ChainLabel) {
|
||||
const connection = await this.props.connection;
|
||||
connection.subscribeConsensus(chain);
|
||||
}
|
||||
|
||||
private async unsubscribeConsensus(chain: Types.ChainLabel) {
|
||||
const connection = await this.props.connection;
|
||||
connection.unsubscribeConsensus(chain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
.Consensus .BlockConsensusMatrice {
|
||||
background-color: #222;
|
||||
font-family: monospace, sans-serif;
|
||||
border-spacing: 0px;
|
||||
border-right: 2px solid lightgrey;
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
|
||||
.Consensus .LargeRow .BlockConsensusMatrice:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .lastInRow {
|
||||
clear: right;
|
||||
width: 99%;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice th {
|
||||
font-weight: normal;
|
||||
border-bottom: 1px dashed #999;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo,
|
||||
.legend {
|
||||
border-bottom: 1px dotted #555555;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo .Tooltip-container {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice .matrice {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice .matrice {
|
||||
font-weight: normal;
|
||||
border-right: 1px dotted #555555;
|
||||
border-bottom: 1px dotted #555;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice tr .matrice:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice .matrixXLegend {
|
||||
text-align: center;
|
||||
border-right: 1px dotted #555555;
|
||||
}
|
||||
|
||||
.Consensus .BlockConsensusMatrice .matrixXLegend:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.Consensus .matrice {
|
||||
text-align: center !important;
|
||||
min-width: 35px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .matrixXLegend,
|
||||
.Consensus .SmallRow .matrice {
|
||||
min-width: 26px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .finalizedInfo {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo {
|
||||
text-align: right;
|
||||
border-right: 1px dashed #999;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.Consensus .finalizedInfo .Tooltip-container {
|
||||
float: none;
|
||||
}
|
||||
|
||||
.Consensus .explicit {
|
||||
fill: #e70e81;
|
||||
}
|
||||
|
||||
.Consensus .nodeName {
|
||||
float: left;
|
||||
padding-right: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .firstInRow .nodeContent {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .firstInRow .nodeName {
|
||||
display: inline-block !important;
|
||||
float: none !important;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.Consensus .flexContainerLargeRow .firstInRow .nodeAddress {
|
||||
display: inline-block !important;
|
||||
float: none !important;
|
||||
vertical-align: middle;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.Consensus .legend {
|
||||
border-right: 1px solid #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.Consensus .first_false .nodeName {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Consensus .legend .nodeAddress {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.Consensus .Row {
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Consensus .Row th,
|
||||
.Consensus .Row td {
|
||||
text-align: left;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.Consensus .Row td {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Consensus .Row .Row-truncate {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.Consensus .Row .Row-Tooltip {
|
||||
position: initial;
|
||||
padding: inherit;
|
||||
}
|
||||
|
||||
.Consensus .Row:hover {
|
||||
background-color: #161616;
|
||||
}
|
||||
|
||||
.Consensus .nodeAddress svg {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Consensus .nodeAddress svg:hover {
|
||||
transform: scale(2);
|
||||
}
|
||||
|
||||
.Consensus .matrice .Icon ~ .Icon {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .matrice .Prevote svg {
|
||||
margin-left: 3px;
|
||||
margin-bottom: -11px;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .matrice .Precommit svg {
|
||||
margin-left: -1px;
|
||||
margin-top: -6px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.Consensus .jdenticonPlaceholder {
|
||||
width: 28px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.Consensus .SmallRow .jdenticonPlaceholder {
|
||||
width: 14px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.Consensus .even {
|
||||
background-color: #333;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import Measure, { BoundingRect, ContentRect } from 'react-measure';
|
||||
import { Types, Maybe } from '../../common';
|
||||
|
||||
import { Icon, Tooltip, PolkadotIcon } from '../';
|
||||
import Jdenticon from './Jdenticon';
|
||||
|
||||
import checkIcon from '../../icons/check.svg';
|
||||
import finalizedIcon from '../../icons/finalized.svg';
|
||||
import hatchingIcon from '../../icons/hatching.svg';
|
||||
|
||||
import './ConsensusBlock.css';
|
||||
|
||||
export namespace ConsensusBlock {
|
||||
export interface Props {
|
||||
authorities: Types.Authority[];
|
||||
authoritySetId: Maybe<Types.AuthoritySetId>;
|
||||
height: Types.BlockNumber;
|
||||
firstInRow: boolean;
|
||||
lastInRow: boolean;
|
||||
compact: boolean;
|
||||
measure: boolean;
|
||||
consensusView: Types.ConsensusView;
|
||||
changeBlocks: (first: boolean, boundsRect: BoundingRect) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
|
||||
public state = {
|
||||
lastConsensusView: '',
|
||||
};
|
||||
|
||||
public shouldComponentUpdate(nextProps: ConsensusBlock.Props): boolean {
|
||||
if (
|
||||
this.props.authorities.length === 0 &&
|
||||
nextProps.authorities.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const positionInfoChanged =
|
||||
this.props.firstInRow !== nextProps.firstInRow ||
|
||||
this.props.lastInRow !== nextProps.lastInRow;
|
||||
if (positionInfoChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newConsensusInfo =
|
||||
JSON.stringify(nextProps.consensusView) !== this.state.lastConsensusView;
|
||||
if (newConsensusInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public render() {
|
||||
this.state.lastConsensusView = JSON.stringify(this.props.consensusView);
|
||||
const finalizedByWhom = this.props.authorities.filter((authority) =>
|
||||
this.isFinalized(authority)
|
||||
);
|
||||
|
||||
const ratio = finalizedByWhom.length + '/' + this.props.authorities.length;
|
||||
let titleFinal = <span>{ratio}</span>;
|
||||
|
||||
const majorityFinalized =
|
||||
finalizedByWhom.length / this.props.authorities.length >= 2 / 3;
|
||||
if (majorityFinalized && !this.props.compact) {
|
||||
titleFinal = <span>FINAL</span>;
|
||||
} else if (majorityFinalized && this.props.compact) {
|
||||
const hash = this.getFinalizedHash(finalizedByWhom[0]);
|
||||
titleFinal = (
|
||||
<Jdenticon
|
||||
hash={hash ? String(hash) : ''}
|
||||
size={this.props.compact ? '14px' : '28px'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnResize = (contentRect: ContentRect) => {
|
||||
this.props.changeBlocks(
|
||||
this.props.firstInRow,
|
||||
contentRect.bounds as BoundingRect
|
||||
);
|
||||
};
|
||||
|
||||
const get = (measureRef: Maybe<(ref: Element | null) => void>) => {
|
||||
return (
|
||||
<div
|
||||
className={`BlockConsensusMatrice
|
||||
${this.props.firstInRow ? 'firstInRow' : ''} ${
|
||||
this.props.lastInRow ? 'lastInRow' : ''
|
||||
}`}
|
||||
key={'block_' + this.props.height}
|
||||
>
|
||||
<table ref={measureRef} key={'block_table_' + this.props.height}>
|
||||
<thead key={'block_thead_' + this.props.height}>
|
||||
<tr className="Row" key={'block_row_' + this.props.height}>
|
||||
{this.props.firstInRow && !this.props.compact ? (
|
||||
<th
|
||||
className="emptylegend"
|
||||
key={'block_row_' + this.props.height + '_empty'}
|
||||
>
|
||||
|
||||
</th>
|
||||
) : null}
|
||||
<th
|
||||
className="legend"
|
||||
key={'block_row_' + this.props.height + '_legend'}
|
||||
>
|
||||
<Tooltip text={`Block number: ${this.props.height}`}>
|
||||
{this.displayBlockNumber()}
|
||||
</Tooltip>
|
||||
</th>
|
||||
<th
|
||||
className="finalizedInfo"
|
||||
key={'block_row_' + this.props.height + '_finalized_info'}
|
||||
>
|
||||
{titleFinal}
|
||||
</th>
|
||||
{this.props.authorities.map((authority) => (
|
||||
<th
|
||||
className="matrixXLegend"
|
||||
key={`${this.props.height}_matrice_x_${authority.Address}`}
|
||||
>
|
||||
{this.getAuthorityContent(authority)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody key={'block_row_' + this.props.height + '_tbody'}>
|
||||
{this.props.authorities.map((authority, row) =>
|
||||
this.renderMatriceRow(authority, this.props.authorities, row)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (this.props.measure) {
|
||||
return (
|
||||
<Measure bounds={true} onResize={handleOnResize}>
|
||||
{({ measureRef }) => get(measureRef)}
|
||||
</Measure>
|
||||
);
|
||||
} else {
|
||||
return get(null);
|
||||
}
|
||||
}
|
||||
|
||||
private displayBlockNumber(): string {
|
||||
const blockNumber = String(this.props.height);
|
||||
return blockNumber.length > 2
|
||||
? '…' + blockNumber.substr(blockNumber.length - 2, blockNumber.length)
|
||||
: blockNumber;
|
||||
}
|
||||
|
||||
private isFinalized(authority: Types.Authority): boolean {
|
||||
if (!authority || authority.NodeId == null || authority.Address == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { Address: addr } = authority;
|
||||
const consensus = this.props.consensusView;
|
||||
|
||||
return (
|
||||
consensus != null &&
|
||||
addr in consensus &&
|
||||
addr in consensus[addr] &&
|
||||
consensus[addr][addr].Finalized === true
|
||||
);
|
||||
}
|
||||
|
||||
private getFinalizedHash(authority: Types.Authority): Maybe<Types.BlockHash> {
|
||||
if (this.isFinalized(authority)) {
|
||||
const { Address: addr } = authority;
|
||||
return this.props.consensusView[addr][addr].FinalizedHash;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderMatriceRow(
|
||||
authority: Types.Authority,
|
||||
authorities: Types.Authority[],
|
||||
row: number
|
||||
): JSX.Element {
|
||||
let finalizedInfo = <span> </span>;
|
||||
let finalizedHash;
|
||||
|
||||
if (authority.NodeId != null && this.isFinalized(authority)) {
|
||||
const matrice = this.props.consensusView[authority.Address][
|
||||
authority.Address
|
||||
];
|
||||
|
||||
finalizedInfo = matrice.ImplicitFinalized ? (
|
||||
<Icon className="implicit" src={finalizedIcon} alt="" />
|
||||
) : (
|
||||
<Icon className="explicit" src={finalizedIcon} alt="" />
|
||||
);
|
||||
|
||||
finalizedHash = matrice.FinalizedHash ? (
|
||||
<Jdenticon hash={matrice.FinalizedHash} size="28px" />
|
||||
) : (
|
||||
<div className="jdenticonPlaceholder"> </div>
|
||||
);
|
||||
}
|
||||
|
||||
const name = authority.Name ? (
|
||||
<span>{authority.Name}</span>
|
||||
) : (
|
||||
<em>no data received from node</em>
|
||||
);
|
||||
const firstName = this.props.firstInRow ? (
|
||||
<td key={'name_' + name} className="nameLegend">
|
||||
{name}
|
||||
</td>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className="Row" key={'block_row_' + this.props.height + '_' + row}>
|
||||
{firstName}
|
||||
<td
|
||||
className="legend"
|
||||
key={'block_row_' + this.props.height + '_' + row + '_legend'}
|
||||
>
|
||||
{this.getAuthorityContent(authority)}
|
||||
</td>
|
||||
<td
|
||||
className="finalizedInfo"
|
||||
key={'block_row_' + this.props.height + '_' + row + '_finalizedInfo'}
|
||||
>
|
||||
{finalizedInfo}
|
||||
{finalizedHash}
|
||||
</td>
|
||||
{authorities.map((columnNode, column) => {
|
||||
const evenOdd = ((row % 2) + column) % 2 === 0 ? 'even' : 'odd';
|
||||
return (
|
||||
<td
|
||||
key={
|
||||
'matrice_' +
|
||||
this.props.height +
|
||||
'_' +
|
||||
row +
|
||||
'_' +
|
||||
authority.Address +
|
||||
'_' +
|
||||
columnNode.Address
|
||||
}
|
||||
className={`matrice ${evenOdd}`}
|
||||
>
|
||||
{this.getCellContent(authority, columnNode)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthorityContent(authority: Types.Authority): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className="nodeContent"
|
||||
key={'authority_' + this.props.height + '_' + authority.Address}
|
||||
>
|
||||
<div className="nodeAddress" key={'authority_' + authority.Address}>
|
||||
<PolkadotIcon
|
||||
account={authority.Address}
|
||||
size={this.props.compact ? 14 : 28}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private getCellContent(
|
||||
rowAuthority: Types.Authority,
|
||||
columnAuthority: Types.Authority
|
||||
) {
|
||||
const consensusInfo =
|
||||
this.props.consensusView &&
|
||||
rowAuthority.Address &&
|
||||
rowAuthority.Address in this.props.consensusView &&
|
||||
columnAuthority.Address in this.props.consensusView[rowAuthority.Address]
|
||||
? this.props.consensusView[rowAuthority.Address][
|
||||
columnAuthority.Address
|
||||
]
|
||||
: null;
|
||||
|
||||
const prevote = consensusInfo && consensusInfo.Prevote;
|
||||
const implicitPrevote = consensusInfo && consensusInfo.ImplicitPrevote;
|
||||
|
||||
const precommit = consensusInfo && consensusInfo.Precommit;
|
||||
const implicitPrecommit = consensusInfo && consensusInfo.ImplicitPrecommit;
|
||||
|
||||
if (rowAuthority.Address !== columnAuthority.Address) {
|
||||
let statPrevote;
|
||||
let statPrecommit;
|
||||
|
||||
if (implicitPrevote) {
|
||||
statPrevote = (
|
||||
<Icon src={checkIcon} className="implicit" alt="Implicit Prevote" />
|
||||
);
|
||||
}
|
||||
if (implicitPrecommit) {
|
||||
statPrecommit = (
|
||||
<Icon src={checkIcon} className="implicit" alt="Implicit Precommit" />
|
||||
);
|
||||
}
|
||||
|
||||
if (prevote) {
|
||||
statPrevote = (
|
||||
<Icon src={checkIcon} className="explicit" alt="Prevote" />
|
||||
);
|
||||
}
|
||||
if (precommit) {
|
||||
statPrecommit = (
|
||||
<Icon src={checkIcon} className="explicit" alt="Precommit" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={'icons_pre'}>
|
||||
{statPrevote}
|
||||
{statPrecommit}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <Icon src={hatchingIcon} className="hatching" alt="" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.Jdenticon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.Jdenticon:hover {
|
||||
transform: scale(2);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import './Jdenticon.css';
|
||||
|
||||
export interface Props {
|
||||
hash: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
class Jdenticon extends React.Component<Props, {}> {
|
||||
private element = null;
|
||||
|
||||
public componentDidUpdate() {
|
||||
const jdenticon = (window as any).jdenticon;
|
||||
if (jdenticon) {
|
||||
jdenticon.update(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const jdenticon = (window as any).jdenticon;
|
||||
if (jdenticon) {
|
||||
jdenticon.update(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { hash, size } = this.props;
|
||||
return (
|
||||
<svg
|
||||
className="Jdenticon"
|
||||
ref={(element) => this.handleRef(element)}
|
||||
width={size}
|
||||
height={size}
|
||||
data-jdenticon-value={hash}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private handleRef(element: any) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
|
||||
export default Jdenticon;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Consensus';
|
||||
export * from './ConsensusBlock';
|
||||
@@ -0,0 +1,38 @@
|
||||
.Filter {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
width: 400px;
|
||||
font-size: 30px;
|
||||
margin-left: -210px;
|
||||
padding: 10px 10px 10px 60px;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.Filter-hidden {
|
||||
bottom: -300px;
|
||||
}
|
||||
|
||||
.Filter input {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 350px;
|
||||
font-size: 30px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.Filter .Icon {
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
top: 17px;
|
||||
font-size: 30px;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../common';
|
||||
import { Node } from '../state';
|
||||
import { Icon } from './';
|
||||
|
||||
import searchIcon from '../icons/search.svg';
|
||||
|
||||
import './Filter.css';
|
||||
|
||||
export namespace Filter {
|
||||
export interface Props {
|
||||
onChange: (value: Maybe<(node: Node) => boolean>) => void;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
value: string;
|
||||
}
|
||||
}
|
||||
|
||||
const ESCAPE_KEY = 27;
|
||||
|
||||
export class Filter extends React.Component<Filter.Props, {}> {
|
||||
public state = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
private filterInput: HTMLInputElement;
|
||||
|
||||
public componentWillMount() {
|
||||
window.addEventListener('keyup', this.onWindowKeyUp);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
window.removeEventListener('keyup', this.onWindowKeyUp);
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(
|
||||
nextProps: Filter.Props,
|
||||
nextState: Filter.State
|
||||
): boolean {
|
||||
return (
|
||||
this.props.onChange !== nextProps.onChange ||
|
||||
this.state.value !== nextState.value
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { value } = this.state;
|
||||
|
||||
let className = 'Filter';
|
||||
|
||||
if (value === '') {
|
||||
className += ' Filter-hidden';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Icon src={searchIcon} />
|
||||
<input
|
||||
ref={this.onRef}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyUp={this.onKeyUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private setValue(value: string) {
|
||||
this.setState({ value });
|
||||
|
||||
this.props.onChange(this.getNodeFilter(value));
|
||||
}
|
||||
|
||||
private onRef = (el: HTMLInputElement) => {
|
||||
this.filterInput = el;
|
||||
};
|
||||
|
||||
private onChange = () => {
|
||||
this.setValue(this.filterInput.value);
|
||||
};
|
||||
|
||||
private onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.keyCode === ESCAPE_KEY) {
|
||||
this.setValue('');
|
||||
}
|
||||
};
|
||||
|
||||
private onWindowKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { value } = this.state;
|
||||
const key = event.key;
|
||||
|
||||
const escape = value && event.keyCode === ESCAPE_KEY;
|
||||
const singleChar = value === '' && key.length === 1;
|
||||
|
||||
if (escape) {
|
||||
this.setValue('');
|
||||
} else if (singleChar) {
|
||||
this.setValue(key);
|
||||
this.filterInput.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private getNodeFilter(value: string): Maybe<(node: Node) => boolean> {
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter = value.toLowerCase();
|
||||
|
||||
return ({ name, city }) => {
|
||||
const matchesName = name.toLowerCase().indexOf(filter) !== -1;
|
||||
const matchesCity =
|
||||
city != null && city.toLowerCase().indexOf(filter) !== -1;
|
||||
|
||||
return matchesName || matchesCity;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.Icon {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
line-height: 1em;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Icon svg {
|
||||
width: auto;
|
||||
height: 1em;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import ReactSVG from 'react-svg';
|
||||
import './Icon.css';
|
||||
|
||||
export interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export class Icon extends React.Component<{}, Props> {
|
||||
public props: Props;
|
||||
|
||||
public shouldComponentUpdate(nextProps: 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;
|
||||
|
||||
return (
|
||||
<ReactSVG
|
||||
key={this.props.src}
|
||||
title={alt}
|
||||
className={`Icon ${className || ''}`}
|
||||
path={src}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
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 cpuIcon from '../../icons/microchip-solid.svg';
|
||||
import memoryIcon from '../../icons/memory-solid.svg';
|
||||
import uploadIcon from '../../icons/cloud-upload.svg';
|
||||
import downloadIcon from '../../icons/cloud-download.svg';
|
||||
import readIcon from '../../icons/arrow-up.svg';
|
||||
import writeIcon from '../../icons/arrow-down.svg';
|
||||
import databaseIcon from '../../icons/database.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 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 unknownImplementationIcon from '../../icons/question-solid.svg';
|
||||
|
||||
const ICONS = {
|
||||
'parity-polkadot': parityPolkadotIcon,
|
||||
'polkadot-js': polkadotJsIcon,
|
||||
'airalab-robonomics': airalabRobonomicsIcon,
|
||||
'substrate-node': paritySubstrateIcon,
|
||||
'edgeware-node': edgewareIcon,
|
||||
'Edgeware Node': edgewareIcon,
|
||||
'joystream-node': joystreamIcon,
|
||||
ChainX: chainXIcon,
|
||||
'ladder-node': ladderIcon,
|
||||
'cennznet-node': cennznetIcon,
|
||||
Darwinia: darwiniaIcon,
|
||||
'Darwinia Testnet': darwiniaIcon,
|
||||
'turing-node': turingIcon,
|
||||
dothereum: dothereumIcon,
|
||||
katalchain: katalchainIcon,
|
||||
'bifrost-node': bifrostIcon,
|
||||
'totem-meccano-node': totemIcon,
|
||||
Totem: totemIcon,
|
||||
};
|
||||
|
||||
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 CPU: Column = {
|
||||
label: '% CPU Use',
|
||||
icon: cpuIcon,
|
||||
width: 40,
|
||||
setting: 'cpu',
|
||||
sortBy: ({ cpu }) => (cpu.length < 3 ? 0 : cpu[cpu.length - 1]),
|
||||
render: ({ cpu, chartstamps }) => {
|
||||
if (cpu.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatCPU}
|
||||
values={cpu}
|
||||
stamps={chartstamps}
|
||||
minScale={100}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MEM: Column = {
|
||||
label: 'Memory Use',
|
||||
icon: memoryIcon,
|
||||
width: 40,
|
||||
setting: 'mem',
|
||||
sortBy: ({ mem }) => (mem.length < 3 ? 0 : mem[mem.length - 1]),
|
||||
render: ({ mem, chartstamps }) => {
|
||||
if (mem.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatMemory}
|
||||
values={mem}
|
||||
stamps={chartstamps}
|
||||
minScale={MEMORY_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
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 DB_CACHE: Column = {
|
||||
label: 'Database Cache Size',
|
||||
icon: databaseIcon,
|
||||
width: 40,
|
||||
setting: 'dbCacheSize',
|
||||
sortBy: ({ dbCacheSize }) =>
|
||||
dbCacheSize.length < 3 ? 0 : dbCacheSize[dbCacheSize.length - 1],
|
||||
render: ({ dbCacheSize, chartstamps }) => {
|
||||
if (dbCacheSize.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBytes}
|
||||
values={dbCacheSize}
|
||||
stamps={chartstamps}
|
||||
minScale={MEMORY_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DISK_READ: Column = {
|
||||
label: 'Disk Read',
|
||||
icon: readIcon,
|
||||
width: 40,
|
||||
setting: 'diskRead',
|
||||
sortBy: ({ diskRead }) =>
|
||||
diskRead.length < 3 ? 0 : diskRead[diskRead.length - 1],
|
||||
render: ({ diskRead, chartstamps }) => {
|
||||
if (diskRead.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBandwidth}
|
||||
values={diskRead}
|
||||
stamps={chartstamps}
|
||||
minScale={MEMORY_SCALE}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DISK_WRITE: Column = {
|
||||
label: 'Disk Write',
|
||||
icon: writeIcon,
|
||||
width: 40,
|
||||
setting: 'diskWrite',
|
||||
sortBy: ({ diskWrite }) =>
|
||||
diskWrite.length < 3 ? 0 : diskWrite[diskWrite.length - 1],
|
||||
render: ({ diskWrite, chartstamps }) => {
|
||||
if (diskWrite.length < 3) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparkline
|
||||
width={44}
|
||||
height={16}
|
||||
stroke={1}
|
||||
format={formatBandwidth}
|
||||
values={diskWrite}
|
||||
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,64 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../common';
|
||||
import { Column } from './';
|
||||
import { Icon, Tooltip } from '../';
|
||||
import { Persistent } from '../../persist';
|
||||
|
||||
import sortAscIcon from '../../icons/triangle-up.svg';
|
||||
import sortDescIcon from '../../icons/triangle-down.svg';
|
||||
|
||||
export namespace HeaderCell {
|
||||
export interface Props {
|
||||
column: Column;
|
||||
index: number;
|
||||
last: number;
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
}
|
||||
|
||||
export class HeaderCell extends React.Component<HeaderCell.Props, {}> {
|
||||
public render() {
|
||||
const { column, index, last } = this.props;
|
||||
const { icon, width, label } = column;
|
||||
const position = index === 0 ? 'left' : index === last ? 'right' : 'center';
|
||||
|
||||
const sortBy = this.props.sortBy.get();
|
||||
const className =
|
||||
column.sortBy == null
|
||||
? ''
|
||||
: sortBy === index || sortBy === ~index
|
||||
? 'HeaderCell-sorted'
|
||||
: 'HeaderCell-sortable';
|
||||
const i =
|
||||
sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon;
|
||||
|
||||
return (
|
||||
<th
|
||||
className={className}
|
||||
style={width ? { width } : undefined}
|
||||
onClick={this.toggleSort}
|
||||
>
|
||||
<Tooltip text={label} inline={true} position={position}>
|
||||
<Icon src={i} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
private toggleSort = () => {
|
||||
const { index, sortBy, column } = this.props;
|
||||
const sortByRaw = sortBy.get();
|
||||
|
||||
if (column.sortBy == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sortByRaw === index) {
|
||||
sortBy.set(~index);
|
||||
} else if (sortByRaw === ~index) {
|
||||
sortBy.set(null);
|
||||
} else {
|
||||
sortBy.set(index);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.List-no-nodes {
|
||||
font-size: 30px;
|
||||
padding-top: 20vh;
|
||||
text-align: center;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.List table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.List thead {
|
||||
background: #393838;
|
||||
}
|
||||
|
||||
.List tbody {
|
||||
font-family: monospace, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../../common';
|
||||
import { Filter } from '../';
|
||||
import { State as AppState, Node } from '../../state';
|
||||
import { Row } from './';
|
||||
import { Persistent, PersistentSet } from '../../persist';
|
||||
import { viewport } from '../../utils';
|
||||
|
||||
const HEADER = 148;
|
||||
const TH_HEIGHT = 35;
|
||||
const TR_HEIGHT = 31;
|
||||
const ROW_MARGIN = 5;
|
||||
|
||||
import './List.css';
|
||||
|
||||
export namespace List {
|
||||
export interface Props {
|
||||
appState: Readonly<AppState>;
|
||||
pins: PersistentSet<Types.NodeName>;
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
filter: Maybe<(node: Node) => boolean>;
|
||||
viewportHeight: number;
|
||||
listStart: number;
|
||||
listEnd: number;
|
||||
}
|
||||
}
|
||||
|
||||
export class List extends React.Component<List.Props, {}> {
|
||||
public state = {
|
||||
filter: null,
|
||||
viewportHeight: viewport().height,
|
||||
listStart: 0,
|
||||
listEnd: 0,
|
||||
};
|
||||
|
||||
private relativeTop = -1;
|
||||
private scrolling = false;
|
||||
|
||||
public componentDidMount() {
|
||||
this.onScroll();
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
window.addEventListener('scroll', this.onScroll);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { selectedColumns } = this.props.appState;
|
||||
const { pins, sortBy } = this.props;
|
||||
const { filter } = this.state;
|
||||
|
||||
let nodes = this.props.appState.nodes.sorted();
|
||||
|
||||
if (filter != null) {
|
||||
nodes = nodes.filter(filter);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="List List-no-nodes">
|
||||
¯\_(ツ)_/¯
|
||||
<br />
|
||||
Nothing matches
|
||||
</div>
|
||||
<Filter onChange={this.onFilterChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { listStart, listEnd } = this.state;
|
||||
|
||||
const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
|
||||
const transform = `translateY(${listStart * TR_HEIGHT}px)`;
|
||||
|
||||
nodes = nodes.slice(listStart, listEnd);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="List" style={{ height }}>
|
||||
<table>
|
||||
<Row.HEADER columns={selectedColumns} sortBy={sortBy} />
|
||||
<tbody style={{ transform }}>
|
||||
{nodes.map((node) => (
|
||||
<Row
|
||||
key={node.id}
|
||||
node={node}
|
||||
pins={pins}
|
||||
columns={selectedColumns}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Filter onChange={this.onFilterChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private onScroll = () => {
|
||||
if (this.scrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relativeTop = divisibleBy(
|
||||
window.scrollY - (HEADER + TR_HEIGHT),
|
||||
TR_HEIGHT * ROW_MARGIN
|
||||
);
|
||||
|
||||
if (this.relativeTop === relativeTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.relativeTop = relativeTop;
|
||||
this.scrolling = true;
|
||||
|
||||
window.requestAnimationFrame(this.onScrollRAF);
|
||||
};
|
||||
|
||||
private onScrollRAF = () => {
|
||||
const { relativeTop } = this;
|
||||
const { viewportHeight } = this.state;
|
||||
const top = Math.max(relativeTop, 0);
|
||||
const height =
|
||||
relativeTop < 0 ? viewportHeight + relativeTop : viewportHeight;
|
||||
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 });
|
||||
}
|
||||
|
||||
this.scrolling = false;
|
||||
};
|
||||
|
||||
private onResize = () => {
|
||||
const viewportHeight = viewport().height;
|
||||
|
||||
this.setState({ viewportHeight });
|
||||
};
|
||||
|
||||
private onFilterChange = (filter: Maybe<(node: Node) => boolean>) => {
|
||||
this.setState({ filter });
|
||||
};
|
||||
}
|
||||
|
||||
function divisibleBy(n: number, dividor: number): number {
|
||||
return n - (n % dividor);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.Row {
|
||||
color: #b5aeae;
|
||||
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;
|
||||
}
|
||||
|
||||
.Row-pinned td:first-child {
|
||||
border-left: 3px solid #e6007a;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.Row-pinned td:last-child {
|
||||
border-right: 3px solid #e6007a;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.Row-pinned.Row-synced {
|
||||
color: #e6007a;
|
||||
}
|
||||
|
||||
.Row-stale {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.Row:hover {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.Row-validator {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Row-validator:hover {
|
||||
transform: scale(2);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../../common';
|
||||
import { Node } from '../../state';
|
||||
import { Persistent, PersistentSet } from '../../persist';
|
||||
import { HeaderCell, Column } from './';
|
||||
|
||||
import './Row.css';
|
||||
|
||||
export namespace Row {
|
||||
export interface Props {
|
||||
node: Node;
|
||||
pins: PersistentSet<Types.NodeName>;
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
export interface State {
|
||||
update: number;
|
||||
}
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
columns: Column[];
|
||||
sortBy: Persistent<Maybe<number>>;
|
||||
}
|
||||
|
||||
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.CPU,
|
||||
Column.MEM,
|
||||
Column.UPLOAD,
|
||||
Column.DOWNLOAD,
|
||||
Column.STATE_CACHE,
|
||||
Column.DB_CACHE,
|
||||
Column.DISK_READ,
|
||||
Column.DISK_WRITE,
|
||||
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,
|
||||
];
|
||||
|
||||
public static HEADER = (props: HeaderProps) => {
|
||||
const { columns, sortBy } = props;
|
||||
const last = columns.length - 1;
|
||||
|
||||
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 {
|
||||
return (
|
||||
this.props.node.id !== nextProps.node.id ||
|
||||
this.state.update !== nextState.update
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { node, columns } = this.props;
|
||||
|
||||
let className = 'Row';
|
||||
|
||||
if (node.propagationTime != null) {
|
||||
className += ' Row-synced';
|
||||
}
|
||||
|
||||
if (node.pinned) {
|
||||
className += ' Row-pinned';
|
||||
}
|
||||
|
||||
if (node.stale) {
|
||||
className += ' Row-stale';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={className} onClick={this.toggle}>
|
||||
{columns.map(({ render }, index) => (
|
||||
<td key={index}>{render(node)}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
public toggle = () => {
|
||||
const { pins, node } = this.props;
|
||||
|
||||
if (node.pinned) {
|
||||
pins.delete(node.name);
|
||||
} else {
|
||||
pins.add(node.name);
|
||||
}
|
||||
};
|
||||
|
||||
private onUpdate = () => {
|
||||
this.setState({ update: this.state.update + 1 });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Column';
|
||||
export * from './List';
|
||||
export * from './Truncate';
|
||||
export * from './Row';
|
||||
export * from './HeaderCell';
|
||||
@@ -0,0 +1,126 @@
|
||||
.Location {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: transparent;
|
||||
border: 2px solid #666;
|
||||
border-radius: 6px;
|
||||
margin-left: -4px;
|
||||
margin-top: -4px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transition: border-color 0.25s linear;
|
||||
}
|
||||
|
||||
.Location-dimmed {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin-left: -1px;
|
||||
margin-top: -1px;
|
||||
z-index: 1;
|
||||
background: #bbb;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.Location-ping {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Location-odd {
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
||||
.Location-synced {
|
||||
z-index: 3;
|
||||
border-color: #e6007a;
|
||||
}
|
||||
|
||||
.Location-synced .Location-ping {
|
||||
border: 1px solid #fff;
|
||||
border-radius: 30px;
|
||||
display: block;
|
||||
animation: ping 1s forwards;
|
||||
}
|
||||
|
||||
.Location:hover {
|
||||
z-index: 4;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.Location-details {
|
||||
min-width: 335px;
|
||||
position: absolute;
|
||||
font-family: monospace, sans-serif;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 20px rgba(0, 0, 0, 0.5);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.Location-quarter0 .Location-details {
|
||||
left: 16px;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
.Location-quarter1 .Location-details {
|
||||
right: 16px;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
.Location-quarter2 .Location-details {
|
||||
left: 16px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.Location-quarter3 .Location-details {
|
||||
right: 16px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.Location-details td {
|
||||
text-align: left;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.Location-details td:nth-child(odd) {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
|
||||
.Location-details td:nth-child(even) {
|
||||
padding-left: 0.2em;
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
from {
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
to {
|
||||
left: -18px;
|
||||
top: -18px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-width: 1px;
|
||||
border-color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.Location-validator {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transform: scale(1.5);
|
||||
transform-origin: right 50%;
|
||||
margin-left: 16px;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
formatNumber,
|
||||
trimHash,
|
||||
milliOrSecond,
|
||||
secondsWithPrecision,
|
||||
} from '../../utils';
|
||||
import { Ago, Icon, PolkadotIcon } from '../';
|
||||
import { Node } from '../../state';
|
||||
|
||||
import nodeIcon from '../../icons/server.svg';
|
||||
import nodeValidatorIcon from '../../icons/shield.svg';
|
||||
import nodeTypeIcon from '../../icons/terminal.svg';
|
||||
import nodeLocationIcon from '../../icons/location.svg';
|
||||
import blockIcon from '../../icons/package.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 './Location.css';
|
||||
|
||||
export namespace Location {
|
||||
export type Quarter = 0 | 1 | 2 | 3;
|
||||
|
||||
export interface Props {
|
||||
node: Node;
|
||||
position: Position;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
left: number;
|
||||
top: number;
|
||||
quarter: Quarter;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
hover: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export class Location extends React.Component<Location.Props, Location.State> {
|
||||
public readonly state = { hover: false };
|
||||
|
||||
public render() {
|
||||
const { node, position, focused } = this.props;
|
||||
const { left, top, quarter } = position;
|
||||
const { height, propagationTime, city } = node;
|
||||
|
||||
if (!city) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let className = `Location Location-quarter${quarter}`;
|
||||
|
||||
if (focused) {
|
||||
if (propagationTime != null) {
|
||||
className += ' Location-synced';
|
||||
} else if (height % 2 === 1) {
|
||||
className += ' Location-odd';
|
||||
}
|
||||
} else {
|
||||
className += ' Location-dimmed';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ left, top }}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseOut={this.onMouseOut}
|
||||
>
|
||||
{this.state.hover ? this.renderDetails() : null}
|
||||
<div className="Location-ping" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderDetails() {
|
||||
const {
|
||||
name,
|
||||
implementation,
|
||||
version,
|
||||
validator,
|
||||
height,
|
||||
hash,
|
||||
blockTime,
|
||||
blockTimestamp,
|
||||
propagationTime,
|
||||
city,
|
||||
} = this.props.node;
|
||||
|
||||
let validatorRow = <div />;
|
||||
|
||||
if (validator) {
|
||||
validatorRow = (
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeValidatorIcon} alt="Node" />
|
||||
</td>
|
||||
<td colSpan={5}>
|
||||
{trimHash(validator, 30)}
|
||||
<span className="Location-validator">
|
||||
<PolkadotIcon account={validator} size={16} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="Location-details Location-details">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeIcon} alt="Node" />
|
||||
</td>
|
||||
<td colSpan={5}>{name}</td>
|
||||
</tr>
|
||||
{validatorRow}
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeTypeIcon} alt="Implementation" />
|
||||
</td>
|
||||
<td colSpan={5}>
|
||||
{implementation} v{version}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={nodeLocationIcon} alt="Location" />
|
||||
</td>
|
||||
<td colSpan={5}>{city}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockIcon} alt="Block" />
|
||||
</td>
|
||||
<td colSpan={5}>#{formatNumber(height)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockHashIcon} alt="Block Hash" />
|
||||
</td>
|
||||
<td colSpan={5}>{trimHash(hash, 20)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Icon src={blockTimeIcon} alt="Block Time" />
|
||||
</td>
|
||||
<td style={{ width: 80 }}>
|
||||
{secondsWithPrecision(blockTime / 1000)}
|
||||
</td>
|
||||
<td>
|
||||
<Icon src={propagationTimeIcon} alt="Block Propagation Time" />
|
||||
</td>
|
||||
<td style={{ width: 58 }}>
|
||||
{propagationTime == null ? '∞' : milliOrSecond(propagationTime)}
|
||||
</td>
|
||||
<td>
|
||||
<Icon src={lastTimeIcon} alt="Last Block Time" />
|
||||
</td>
|
||||
<td style={{ minWidth: 82 }}>
|
||||
<Ago when={blockTimestamp} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
private onMouseOver = () => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
private onMouseOut = () => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.Map {
|
||||
min-width: 1350px;
|
||||
background: url('../../assets/world-map.svg') no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../../common';
|
||||
import { Filter } from '../';
|
||||
import { State as AppState, Node } from '../../state';
|
||||
import { Location } from './';
|
||||
import { viewport } from '../../utils';
|
||||
|
||||
const MAP_RATIO = 800 / 350;
|
||||
const MAP_HEIGHT_ADJUST = 400 / 350;
|
||||
const HEADER = 148;
|
||||
|
||||
import './Map.css';
|
||||
|
||||
export namespace Map {
|
||||
export interface Props {
|
||||
appState: Readonly<AppState>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
filter: Maybe<(node: Node) => boolean>;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
}
|
||||
|
||||
export class Map extends React.Component<Map.Props, Map.State> {
|
||||
public state: Map.State = {
|
||||
filter: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
public componentWillMount() {
|
||||
this.onResize();
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { appState } = this.props;
|
||||
const { filter } = this.state;
|
||||
const nodes = appState.nodes.sorted();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="Map">
|
||||
{nodes.map((node) => {
|
||||
const { lat, lon } = node;
|
||||
|
||||
const focused = filter == null || filter(node);
|
||||
|
||||
if (lat == null || lon == null) {
|
||||
// Skip nodes with unknown location
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = this.pixelPosition(lat, lon);
|
||||
|
||||
return (
|
||||
<Location
|
||||
key={node.id}
|
||||
position={position}
|
||||
focused={focused}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Filter onChange={this.onFilterChange} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private pixelPosition(
|
||||
lat: Types.Latitude,
|
||||
lon: Types.Longitude
|
||||
): Location.Position {
|
||||
const { state } = this;
|
||||
|
||||
// Longitude ranges -180 (west) to +180 (east)
|
||||
// Latitude ranges +90 (north) to -90 (south)
|
||||
const left = Math.round(((180 + lon) / 360) * state.width + state.left);
|
||||
const top = Math.round(
|
||||
((90 - lat) / 180) * state.height * MAP_HEIGHT_ADJUST + state.top
|
||||
);
|
||||
|
||||
let quarter: Location.Quarter = 0;
|
||||
|
||||
if (lon > 0) {
|
||||
quarter = (quarter | 1) as Location.Quarter;
|
||||
}
|
||||
|
||||
if (lat < 0) {
|
||||
quarter = (quarter | 2) as Location.Quarter;
|
||||
}
|
||||
|
||||
return { left, top, quarter };
|
||||
}
|
||||
|
||||
private onResize: () => void = () => {
|
||||
const vp = viewport();
|
||||
|
||||
vp.width = Math.max(1350, vp.width);
|
||||
vp.height -= HEADER;
|
||||
|
||||
const ratio = vp.width / vp.height;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
|
||||
if (ratio >= MAP_RATIO) {
|
||||
width = Math.round(vp.height * MAP_RATIO);
|
||||
height = Math.round(vp.height);
|
||||
left = (vp.width - width) / 2;
|
||||
} else {
|
||||
width = Math.round(vp.width);
|
||||
height = Math.round(vp.width / MAP_RATIO);
|
||||
top = (vp.height - height) / 2;
|
||||
}
|
||||
|
||||
this.setState({ top, left, width, height });
|
||||
};
|
||||
|
||||
private onFilterChange = (filter: Maybe<(node: Node) => boolean>) => {
|
||||
this.setState({ filter });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Map';
|
||||
export * from './Location';
|
||||
@@ -0,0 +1,17 @@
|
||||
.OfflineIndicator {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 10;
|
||||
background: #c00;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
padding: 16px;
|
||||
border-radius: 50px;
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0 3px 20px;
|
||||
}
|
||||
|
||||
.OfflineIndicator-upgrade {
|
||||
background: #282;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import './OfflineIndicator.css';
|
||||
import { Icon } from './Icon';
|
||||
import { State } from '../state';
|
||||
import offlineIcon from '../icons/zap.svg';
|
||||
import upgradeIcon from '../icons/flame.svg';
|
||||
|
||||
export namespace OfflineIndicator {
|
||||
export interface Props {
|
||||
status: State['status'];
|
||||
}
|
||||
}
|
||||
|
||||
export function OfflineIndicator(
|
||||
props: OfflineIndicator.Props
|
||||
): React.ReactElement<any> | null {
|
||||
switch (props.status) {
|
||||
case 'online':
|
||||
return null;
|
||||
case 'offline':
|
||||
return (
|
||||
<div className="OfflineIndicator">
|
||||
<Icon src={offlineIcon} alt="Offline" />
|
||||
</div>
|
||||
);
|
||||
case 'upgrade-requested':
|
||||
return (
|
||||
<div className="OfflineIndicator OfflineIndicator-upgrade">
|
||||
<Icon src={upgradeIcon} alt="New Version Available" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2018 Paritytech via paritytech/oo7/polkadot-identicon
|
||||
// Copyright 2018 @polkadot/ui-shared authors & contributors
|
||||
// This software may be modified and distributed under the terms
|
||||
// of the Apache-2.0 license. See the LICENSE file for details.
|
||||
|
||||
// This has been converted from the original version that can be found at
|
||||
//
|
||||
// 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';
|
||||
|
||||
interface Circle {
|
||||
cx: number;
|
||||
cy: number;
|
||||
fill: string;
|
||||
r: number;
|
||||
}
|
||||
|
||||
interface Scheme {
|
||||
freq: number;
|
||||
colors: number[];
|
||||
}
|
||||
|
||||
const blake2 = (value: Uint8Array): Uint8Array => blake2AsU8a(value, 512);
|
||||
|
||||
const S = 64;
|
||||
const C = S / 2;
|
||||
const Z = (S / 64) * 5;
|
||||
const ZERO = blake2(new Uint8Array(32));
|
||||
|
||||
const SCHEMA: Scheme[] = [
|
||||
// target
|
||||
{
|
||||
freq: 1,
|
||||
colors: [0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 1],
|
||||
},
|
||||
// cube
|
||||
{
|
||||
freq: 20,
|
||||
colors: [0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 5],
|
||||
},
|
||||
// quazar
|
||||
{
|
||||
freq: 16,
|
||||
colors: [1, 2, 3, 1, 2, 4, 5, 5, 4, 1, 2, 3, 1, 2, 4, 5, 5, 4, 0],
|
||||
},
|
||||
// flower
|
||||
{
|
||||
freq: 32,
|
||||
colors: [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 3],
|
||||
},
|
||||
// cyclic
|
||||
{
|
||||
freq: 32,
|
||||
colors: [0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
// vmirror
|
||||
{
|
||||
freq: 128,
|
||||
colors: [0, 1, 2, 3, 4, 5, 3, 4, 2, 0, 1, 6, 7, 8, 9, 7, 8, 6, 10],
|
||||
},
|
||||
// hmirror
|
||||
{
|
||||
freq: 128,
|
||||
colors: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 8, 6, 7, 5, 3, 4, 2, 11],
|
||||
},
|
||||
];
|
||||
|
||||
const OUTER_CIRCLE: Circle = {
|
||||
cx: C,
|
||||
cy: C,
|
||||
r: C,
|
||||
fill: '#eee',
|
||||
};
|
||||
|
||||
function getRotation(
|
||||
isSixPoint: boolean
|
||||
): {
|
||||
r: number;
|
||||
ro2: number;
|
||||
r3o4: number;
|
||||
ro4: number;
|
||||
rroot3o2: number;
|
||||
rroot3o4: number;
|
||||
} {
|
||||
const r = isSixPoint ? (C / 8) * 5 : (C / 4) * 3;
|
||||
const rroot3o2 = (r * Math.sqrt(3)) / 2;
|
||||
const ro2 = r / 2;
|
||||
const rroot3o4 = (r * Math.sqrt(3)) / 4;
|
||||
const ro4 = r / 4;
|
||||
const r3o4 = (r * 3) / 4;
|
||||
|
||||
return { r, ro2, r3o4, ro4, rroot3o2, rroot3o4 };
|
||||
}
|
||||
|
||||
function getCircleXY(isSixPoint: boolean): Array<[number, number]> {
|
||||
const { r, ro2, r3o4, ro4, rroot3o2, rroot3o4 } = getRotation(isSixPoint);
|
||||
|
||||
return [
|
||||
[C, C - r],
|
||||
[C, C - ro2],
|
||||
[C - rroot3o4, C - r3o4],
|
||||
[C - rroot3o2, C - ro2],
|
||||
[C - rroot3o4, C - ro4],
|
||||
[C - rroot3o2, C],
|
||||
[C - rroot3o2, C + ro2],
|
||||
[C - rroot3o4, C + ro4],
|
||||
[C - rroot3o4, C + r3o4],
|
||||
[C, C + r],
|
||||
[C, C + ro2],
|
||||
[C + rroot3o4, C + r3o4],
|
||||
[C + rroot3o2, C + ro2],
|
||||
[C + rroot3o4, C + ro4],
|
||||
[C + rroot3o2, C],
|
||||
[C + rroot3o2, C - ro2],
|
||||
[C + rroot3o4, C - ro4],
|
||||
[C + rroot3o4, C - r3o4],
|
||||
[C, C],
|
||||
];
|
||||
}
|
||||
|
||||
function findScheme(d: number): Scheme {
|
||||
let sum = 0;
|
||||
const schema = SCHEMA.find((s): boolean => {
|
||||
sum += s.freq;
|
||||
|
||||
return d < sum;
|
||||
});
|
||||
|
||||
if (!schema) {
|
||||
throw new Error('Unable to find schema');
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function addressToId(address: string): Uint8Array {
|
||||
return blake2(decodeAddress(address)).map(
|
||||
(x, i): number => (x + 256 - ZERO[i]) % 256
|
||||
);
|
||||
}
|
||||
|
||||
function getColors(address: string): string[] {
|
||||
const total = SCHEMA.map((s): number => s.freq).reduce(
|
||||
(a, b): number => a + b
|
||||
);
|
||||
const id = addressToId(address);
|
||||
const d = Math.floor((id[30] + id[31] * 256) % total);
|
||||
const rot = (id[28] % 6) * 3;
|
||||
const sat = (Math.floor((id[29] * 70) / 256 + 26) % 80) + 30;
|
||||
const scheme = findScheme(d);
|
||||
const palette = Array.from(id).map((x, i): string => {
|
||||
const b = (x + (i % 28) * 58) % 256;
|
||||
|
||||
if (b === 0) {
|
||||
return '#444';
|
||||
} else if (b === 255) {
|
||||
return 'transparent';
|
||||
}
|
||||
|
||||
const h = Math.floor(((b % 64) * 360) / 64);
|
||||
const l = [53, 15, 35, 75][Math.floor(b / 64)];
|
||||
|
||||
return `hsl(${h}, ${sat}%, ${l}%)`;
|
||||
});
|
||||
|
||||
return scheme.colors.map(
|
||||
(_, i): string => palette[scheme.colors[i < 18 ? (i + rot) % 18 : 18]]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Generate a array of the circles that make up an indenticon
|
||||
*/
|
||||
export default 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],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export namespace PolkadotIcon {
|
||||
export interface Props {
|
||||
account: string;
|
||||
size: number;
|
||||
}
|
||||
}
|
||||
|
||||
export class PolkadotIcon extends React.Component<PolkadotIcon.Props, {}> {
|
||||
public render(): React.ReactNode {
|
||||
const { account, size } = this.props;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64">
|
||||
{generate(account, false).map(this.renderCircle)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCircle = (
|
||||
{ cx, cy, r, fill }: Circle,
|
||||
key: number
|
||||
): React.ReactNode => {
|
||||
return <circle key={key} cx={cx} cy={cy} r={r} fill={fill} />;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.Setting {
|
||||
color: #635f5f;
|
||||
padding: 0;
|
||||
margin: 0 0 8px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Setting-on {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.Setting .Icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.Setting-switch {
|
||||
width: 40px;
|
||||
height: 18px;
|
||||
border-radius: 18px;
|
||||
display: block;
|
||||
float: right;
|
||||
position: relative;
|
||||
background: #444;
|
||||
transition: background-color 0.15s linear, border-color 0.15s linear;
|
||||
}
|
||||
|
||||
.Setting-on .Setting-switch {
|
||||
background: #e6007a;
|
||||
border-color: #e6007a;
|
||||
}
|
||||
|
||||
.Setting-knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 19px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
transition: left 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.Setting-on .Setting-knob {
|
||||
left: 22px;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { Icon } from '../';
|
||||
import { State } from '../../state';
|
||||
import { PersistentObject } from '../../persist';
|
||||
|
||||
import './Setting.css';
|
||||
|
||||
export namespace Setting {
|
||||
export interface Props {
|
||||
icon: string;
|
||||
label: string;
|
||||
setting: keyof State.Settings;
|
||||
settings: PersistentObject<State.Settings>;
|
||||
}
|
||||
}
|
||||
|
||||
export class Setting extends React.Component<Setting.Props, {}> {
|
||||
public render() {
|
||||
const { icon, label, setting, settings } = this.props;
|
||||
|
||||
const checked = settings.get(setting);
|
||||
const className = checked ? 'Setting Setting-on' : 'Setting';
|
||||
|
||||
return (
|
||||
<div className={className} onClick={this.toggle}>
|
||||
<Icon src={icon} alt={label} />
|
||||
{label}
|
||||
<span className="Setting-switch">
|
||||
<span className="Setting-knob" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private toggle = () => {
|
||||
const { setting, settings } = this.props;
|
||||
|
||||
settings.set(setting, !settings.get(setting));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.Settings {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.Settings-category {
|
||||
text-align: left;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 2em 0;
|
||||
}
|
||||
|
||||
.Settings-category h2 {
|
||||
padding: 0;
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 20px;
|
||||
font-weight: 100;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
import { Maybe } from '../../common';
|
||||
import { State as AppState } from '../../state';
|
||||
import { Setting } from './';
|
||||
import { Row } from '../List';
|
||||
import { PersistentObject } from '../../persist';
|
||||
|
||||
import './Settings.css';
|
||||
|
||||
export namespace Settings {
|
||||
export type Display = 'list' | 'map' | 'settings';
|
||||
|
||||
export interface Props {
|
||||
settings: PersistentObject<AppState.Settings>;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
display: Display;
|
||||
filter: Maybe<string>;
|
||||
}
|
||||
}
|
||||
|
||||
export class Settings extends React.Component<Settings.Props, {}> {
|
||||
public render() {
|
||||
const { settings } = this.props;
|
||||
|
||||
return (
|
||||
<div className="Settings">
|
||||
<div className="Settings-category">
|
||||
<h1>List View</h1>
|
||||
<h2>Visible Columns</h2>
|
||||
{Row.columns.map(({ label, icon, setting }, index) => {
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Setting
|
||||
key={index}
|
||||
setting={setting}
|
||||
settings={settings}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Settings';
|
||||
export * from './Setting';
|
||||
@@ -0,0 +1,6 @@
|
||||
.Sparkline {
|
||||
fill: currentcolor;
|
||||
fill-opacity: 0.35;
|
||||
stroke: currentcolor;
|
||||
margin: 0 -1px -3px -1px;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from 'react';
|
||||
import { Types, Maybe } from '../common';
|
||||
import sparkline from '@fnando/sparkline';
|
||||
import { Tooltip } from './';
|
||||
|
||||
import './Sparkline.css';
|
||||
|
||||
export namespace Sparkline {
|
||||
export interface Props {
|
||||
stroke: number;
|
||||
width: number;
|
||||
height: number;
|
||||
values: number[];
|
||||
stamps?: Types.Timestamp[];
|
||||
minScale?: number;
|
||||
format?: (value: number, stamp: Maybe<Types.Timestamp>) => string;
|
||||
}
|
||||
}
|
||||
|
||||
export class Sparkline extends React.Component<Sparkline.Props, {}> {
|
||||
private el: SVGSVGElement;
|
||||
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;
|
||||
|
||||
if (
|
||||
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;
|
||||
};
|
||||
|
||||
private onTooltipInit = (update: Tooltip.UpdateCallback) => {
|
||||
this.update = update;
|
||||
};
|
||||
|
||||
private onMouseMove = (
|
||||
_event: MouseEvent,
|
||||
data: { value: number; index: number }
|
||||
) => {
|
||||
const { format, stamps } = this.props;
|
||||
const str = format
|
||||
? format(data.value, stamps ? stamps[data.index] : null)
|
||||
: `${data.value}`;
|
||||
this.update(str);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.Tile {
|
||||
font-size: 2.5em;
|
||||
text-align: left;
|
||||
width: 260px;
|
||||
height: 100px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Tile-label {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 100px;
|
||||
right: 0;
|
||||
font-size: 0.4em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.Tile-content {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 100px;
|
||||
right: 0;
|
||||
font-weight: 300;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.Tile .Icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 20px;
|
||||
font-size: 0.8em;
|
||||
padding: 0.5em;
|
||||
border-radius: 1.25em;
|
||||
border: 2px solid #e6007a;
|
||||
color: #e6007a;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import './Tile.css';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export namespace Tile {
|
||||
export interface Props {
|
||||
title: string;
|
||||
icon: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
}
|
||||
|
||||
export function Tile(props: Tile.Props) {
|
||||
return (
|
||||
<div className="Tile">
|
||||
<Icon src={props.icon} alt={props.title} />
|
||||
<span className="Tile-label">{props.title}</span>
|
||||
<span className="Tile-content">{props.children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.Tooltip {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
padding: 3px 5px;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
top: -32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.Tooltip::after {
|
||||
content: ' ';
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -6px;
|
||||
margin-left: -6px;
|
||||
border-top: 6px #000 solid;
|
||||
border-left: 6px transparent solid;
|
||||
border-right: 6px transparent solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.Tooltip-left {
|
||||
left: 10px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.Tooltip-left::after {
|
||||
left: 3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Tooltip-right {
|
||||
left: initial;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.Tooltip-right::after {
|
||||
left: initial;
|
||||
right: 3px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Tooltip.Tooltip-copied {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.Tooltip-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline .Tooltip-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.Tooltip-container-inline .Tooltip-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.Tooltip-container:hover .Tooltip {
|
||||
display: block;
|
||||
animation: show 0.15s forwards;
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import './Tooltip.css';
|
||||
|
||||
export namespace Tooltip {
|
||||
export interface Props {
|
||||
text: string;
|
||||
copy?: boolean;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
position?: 'left' | 'right' | 'center';
|
||||
onInit?: (update: UpdateCallback) => void;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
copied: boolean;
|
||||
}
|
||||
|
||||
export type UpdateCallback = (text: string) => void;
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
|
||||
export class Tooltip extends React.Component<Tooltip.Props, Tooltip.State> {
|
||||
public state = { copied: false };
|
||||
|
||||
private el: HTMLDivElement;
|
||||
private timer: NodeJS.Timer;
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.props.onInit) {
|
||||
this.props.onInit(this.update);
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { text, inline, 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}`;
|
||||
}
|
||||
|
||||
if (copied) {
|
||||
tooltipClass += ' Tooltip-copied';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClass} onClick={this.onClick}>
|
||||
<div className={tooltipClass} ref={this.onRef}>
|
||||
{copied ? 'Copied to clipboard!' : text}
|
||||
</div>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onRef = (el: HTMLDivElement) => {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
private update = (text: string) => {
|
||||
this.el.textContent = text;
|
||||
};
|
||||
|
||||
private onClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (this.props.copy !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(this.props.text);
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
clearTimeout(this.timer);
|
||||
|
||||
this.setState({ copied: true });
|
||||
this.timer = setTimeout(this.restore, 2000);
|
||||
};
|
||||
|
||||
private restore = () => {
|
||||
this.setState({ copied: false });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from './AllChains';
|
||||
export * from './Chains';
|
||||
export * from './Chain';
|
||||
export * from './List';
|
||||
export * from './Map';
|
||||
export * from './Settings';
|
||||
export * from './Consensus';
|
||||
export * from './Icon';
|
||||
export * from './Tile';
|
||||
export * from './Ago';
|
||||
export * from './OfflineIndicator';
|
||||
export * from './Sparkline';
|
||||
export * from './Tooltip';
|
||||
export * from './Filter';
|
||||
export * from './PolkadotIcon';
|
||||
Reference in New Issue
Block a user