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:
Daniel Maricic
2020-04-06 15:38:45 +02:00
committed by GitHub
parent 20a0283380
commit bb8e804567
322 changed files with 10896 additions and 10602 deletions
+308
View File
@@ -0,0 +1,308 @@
import { Types } from './common';
import { State, UpdateBound } from './state';
// Number of blocks which are kept in memory
const BLOCKS_LIMIT = 50;
export class AfgHandling {
private updateState: UpdateBound;
private getState: () => Readonly<State>;
constructor(updateState: UpdateBound, getState: () => Readonly<State>) {
this.updateState = updateState;
this.getState = getState;
}
public receivedAuthoritySet(
authoritySetId: Types.AuthoritySetId,
authorities: Types.Authorities
) {
if (
this.getState().authoritySetId != null &&
authoritySetId !== this.getState().authoritySetId
) {
// the visualization is restarted when we receive a new authority set
this.updateState({
authoritySetId,
authorities,
consensusInfo: [],
displayConsensusLoadingScreen: false,
});
} else if (this.getState().authoritySetId == null) {
// initial display
this.updateState({
authoritySetId,
authorities,
consensusInfo: [],
displayConsensusLoadingScreen: true,
});
}
return null;
}
public receivedFinalized(
addr: Types.Address,
finalizedNumber: Types.BlockNumber,
finalizedHash: Types.BlockHash
) {
const state = this.getState();
if (finalizedNumber < state.best - BLOCKS_LIMIT) {
return;
}
const data = {
Finalized: true,
FinalizedHash: finalizedHash,
FinalizedHeight: finalizedNumber,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
} as Types.ConsensusDetail;
this.initialiseConsensusView(
state.consensusInfo,
finalizedNumber,
addr,
addr
);
this.updateConsensusInfo(
state.consensusInfo,
finalizedNumber,
addr,
addr,
data as Partial<Types.ConsensusDetail>
);
// Finalizing a block implicitly includes finalizing all
// preceding blocks. This function marks the preceding
// blocks as implicitly finalized on and stores a pointer
// to the block which contains the explicit finalization.
const op = (i: Types.BlockNumber, index: number): boolean => {
const consensusDetail = state.consensusInfo[index][1][addr][addr];
if (consensusDetail.Finalized || consensusDetail.ImplicitFinalized) {
return false;
}
state.consensusInfo[index][1][addr][addr] = {
Finalized: true,
FinalizedHeight: i,
ImplicitFinalized: true,
ImplicitPointer: finalizedNumber,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
ImplicitPrevote: true,
ImplicitPrecommit: true,
};
return true;
};
this.backfill(state.consensusInfo, finalizedNumber, op, addr, addr);
this.pruneBlocks(state.consensusInfo);
this.updateState({ consensusInfo: state.consensusInfo });
}
public receivedPre(
addr: Types.Address,
height: Types.BlockNumber,
voter: Types.Address,
what: string
) {
const state = this.getState();
if (height < state.best - BLOCKS_LIMIT) {
return;
}
const data = what === 'prevote' ? { Prevote: true } : { Precommit: true };
this.initialiseConsensusView(state.consensusInfo, height, addr, voter);
this.updateConsensusInfo(
state.consensusInfo,
height,
addr,
voter,
data as Partial<Types.ConsensusDetail>
);
// A Prevote or Precommit on a block implicitly includes
// a vote on all preceding blocks. This function marks
// the preceding blocks as implicitly voted on and stores
// a pointer to the block which contains the explicit vote.
const op = (index: number): boolean => {
const consensusDetail = state.consensusInfo[index][1][addr][voter];
if (
what === 'prevote' &&
(consensusDetail.Prevote || consensusDetail.ImplicitPrevote)
) {
return false;
}
if (
what === 'precommit' &&
(consensusDetail.Precommit || consensusDetail.ImplicitPrecommit) &&
// because of extrapolation a prevote needs to be set as well.
// if it is not we continue backfilling (and set it during that process).
(consensusDetail.Prevote || consensusDetail.ImplicitPrevote)
) {
return false;
}
if (what === 'prevote') {
consensusInfo[index][1][addr][voter].ImplicitPrevote = true;
} else if (what === 'precommit') {
consensusInfo[index][1][addr][voter].ImplicitPrecommit = true;
// Extrapolate. Precommit implies Prevote.
consensusInfo[index][1][addr][voter].ImplicitPrevote = true;
}
consensusInfo[index][1][addr][voter].ImplicitPointer = height;
return true;
};
const consensusInfo = this.getState().consensusInfo;
this.backfill(consensusInfo, height, op, addr, voter);
this.pruneBlocks(consensusInfo);
this.updateState({ consensusInfo });
}
// Initializes the `ConsensusView` with empty objects.
private initialiseConsensusView(
consensusInfo: Types.ConsensusInfo,
height: Types.BlockNumber,
own: Types.Address,
other: Types.Address
) {
const found = consensusInfo.find(([blockNumber]) => blockNumber === height);
let consensusView;
if (found) {
[, consensusView] = found;
this.initialiseConsensusViewByRef(consensusView, own, other);
} else {
consensusView = {} as Types.ConsensusView;
this.initialiseConsensusViewByRef(consensusView, own, other);
const item: Types.ConsensusItem = [height, consensusView];
const insertPos = consensusInfo.findIndex(
([elHeight]) => elHeight < height
);
if (insertPos >= 0) {
consensusInfo.splice(insertPos, 0, item);
} else {
consensusInfo.push(item);
}
}
}
// Initializes the `ConsensusView` with empty objects.
private initialiseConsensusViewByRef(
consensusView: Types.ConsensusView,
own: Types.Address,
other: Types.Address
) {
if (!consensusView[own]) {
consensusView[own] = {} as Types.ConsensusState;
}
if (!consensusView[own][other]) {
consensusView[own][other] = {} as Types.ConsensusDetail;
}
}
// Fill the block cache back from the `to` number to the last block.
// The function `f` is used to evaluate if we should continue backfilling.
// `f` returns false when backfilling the cache should be stopped, true to continue.
//
// Returns block number until which we backfilled.
private backfill(
consensusInfo: Types.ConsensusInfo,
start: Types.BlockNumber,
f: (i: Types.BlockNumber, index: number) => boolean,
own: Types.Address,
other: Types.Address
) {
// if this is the first block then we don't fill latter blocks
// if there is only one block, then it also doesn't make
// sense to backfill, because we could potentially backfill
// until 0 (which could be unfortunate if the first received
// block is e.g. 28317.
if (consensusInfo.length < 2) {
return;
}
let firstBlockNumber = consensusInfo[consensusInfo.length - 1][0];
const limit = this.getState().best - BLOCKS_LIMIT;
if (firstBlockNumber < limit) {
firstBlockNumber = limit as Types.BlockNumber;
}
if (start - 1 < firstBlockNumber) {
// if the first block which would be backfilled is already
// less than the first block number we can abort.
//
// this can happen if e.g. one authority is hanging behind,
// most of them could e.g. be at 3000 and one is hanging behind
// and sending info for 2000. then we can't start backfilling
// from 2000.
return;
}
let counter = 0;
while (start-- > 0) {
counter++;
if (counter >= BLOCKS_LIMIT) {
break;
}
const startBlockNumber = start as Types.BlockNumber;
this.initialiseConsensusView(consensusInfo, startBlockNumber, own, other);
const index = consensusInfo.findIndex(
([blockNumber]) => blockNumber === start
);
const cont = f(start, index);
if (!cont) {
break;
}
// we don't want to fill into nirvana
const firstBlockReached = startBlockNumber <= firstBlockNumber;
if (firstBlockReached) {
break;
}
}
}
private updateConsensusInfo(
consensusInfo: Types.ConsensusInfo,
height: Types.BlockNumber,
addr: Types.Address,
voter: Types.Address,
data: Partial<Types.ConsensusDetail>
) {
const found = consensusInfo.findIndex(
([blockNumber]) => blockNumber === height
);
if (found < 0) {
return;
}
for (const k in data) {
if (data.hasOwnProperty(k)) {
consensusInfo[found][1][addr][voter][k] = data[k];
}
}
}
private pruneBlocks(consensusInfo: Types.ConsensusInfo) {
if (consensusInfo.length >= BLOCKS_LIMIT) {
consensusInfo.length = BLOCKS_LIMIT;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
.App {
text-align: left;
font-family: Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
}
.App-no-telemetry {
width: 100vw;
/* height: 100vh; */
/* line-height: 80vh; */
font-size: 56px;
font-weight: 100;
text-align: center;
color: #888;
}
+9
View File
@@ -0,0 +1,9 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});
+254
View File
@@ -0,0 +1,254 @@
import * as React from 'react';
import { Types, SortedCollection, Maybe, Compare } from './common';
import { AllChains, Chains, Chain, Ago, OfflineIndicator } from './components';
import { Row, Column } from './components/List';
import { Connection } from './Connection';
import { Persistent, PersistentObject, PersistentSet } from './persist';
import { State, Node, ChainData, PINNED_CHAIN } from './state';
import { getHashData } from './utils';
import stable from 'stable';
import './App.css';
export default class App extends React.Component<{}, State> {
public state: State;
private chainsCache: ChainData[] = [];
private readonly settings: PersistentObject<State.Settings>;
private readonly pins: PersistentSet<Types.NodeName>;
private readonly sortBy: Persistent<Maybe<number>>;
private readonly connection: Promise<Connection>;
constructor(props: {}) {
super(props);
this.settings = new PersistentObject(
'settings',
{
validator: true,
location: true,
implementation: true,
networkId: false,
peers: true,
txs: true,
cpu: true,
mem: true,
upload: false,
download: false,
stateCacheSize: false,
dbCacheSize: false,
diskRead: false,
diskWrite: false,
blocknumber: true,
blockhash: true,
blocktime: true,
finalized: false,
finalizedhash: false,
blockpropagation: true,
blocklasttime: false,
uptime: false,
networkstate: false,
},
(settings) => {
const selectedColumns = this.selectedColumns(settings);
this.sortBy.set(null);
this.setState({ settings, selectedColumns, sortBy: null });
}
);
this.pins = new PersistentSet<Types.NodeName>('pinned_names', (pins) => {
const { nodes } = this.state;
nodes.mutEachAndSort((node) => node.setPinned(pins.has(node.name)));
this.setState({ nodes, pins });
});
this.sortBy = new Persistent<Maybe<number>>('sortBy', null, (sortBy) => {
const compare = this.getComparator(sortBy);
this.state.nodes.setComparator(compare);
this.setState({ sortBy });
});
const { tab = '' } = getHashData();
this.state = {
status: 'offline',
best: 0 as Types.BlockNumber,
finalized: 0 as Types.BlockNumber,
consensusInfo: new Array() as Types.ConsensusInfo,
displayConsensusLoadingScreen: true,
authorities: new Array() as Types.Authorities,
authoritySetId: null,
sendFinality: false,
blockTimestamp: 0 as Types.Timestamp,
blockAverage: null,
timeDiff: 0 as Types.Milliseconds,
subscribed: null,
chains: new Map(),
nodes: new SortedCollection(Node.compare),
settings: this.settings.raw(),
pins: this.pins.get(),
sortBy: this.sortBy.get(),
selectedColumns: this.selectedColumns(this.settings.raw()),
tab,
};
this.state.nodes.setComparator(this.getComparator(this.sortBy.get()));
this.connection = Connection.create(this.pins, (changes) => {
if (changes) {
this.setState(changes);
}
return this.state;
});
setInterval(() => (this.chainsCache = []), 10000); // Wipe sorted chains cache every 10 seconds
}
public render() {
const { timeDiff, subscribed, status, tab } = this.state;
const chains = this.chains();
Ago.timeDiff = timeDiff;
if (chains.length === 0) {
return (
<div className="App App-no-telemetry">
<OfflineIndicator status={status} />
Waiting for telemetry&hellip;
</div>
);
}
const overlay =
tab === 'all-chains' ? (
<AllChains
chains={chains}
subscribed={subscribed}
connection={this.connection}
/>
) : null;
return (
<div className="App">
<OfflineIndicator status={status} />
<Chains
chains={chains}
subscribed={subscribed}
connection={this.connection}
/>
<Chain
appState={this.state}
connection={this.connection}
settings={this.settings}
pins={this.pins}
sortBy={this.sortBy}
/>
{overlay}
</div>
);
}
public componentWillMount() {
window.addEventListener('keydown', this.onKeyPress);
window.addEventListener('hashchange', this.onHashChange);
}
public componentWillUnmount() {
window.removeEventListener('keydown', this.onKeyPress);
window.removeEventListener('hashchange', this.onHashChange);
}
private onKeyPress = (event: KeyboardEvent) => {
if (event.keyCode !== 9) {
// TAB KEY
return;
}
event.preventDefault();
const { subscribed } = this.state;
const chains = Array.from(this.state.chains.keys());
let index = 0;
if (subscribed) {
index = (chains.indexOf(subscribed) + 1) % chains.length;
// Do nothing if it's the same chain
if (chains[index] === subscribed) {
return;
}
}
this.connection.then((connection) => {
connection.subscribe(chains[index]);
});
};
private onHashChange = () => {
const { tab = '' } = getHashData();
this.setState({ tab });
};
private chains(): ChainData[] {
if (this.chainsCache.length === this.state.chains.size) {
return this.chainsCache;
}
this.chainsCache = stable.inplace(
Array.from(this.state.chains.values()),
(a, b) => {
if (a.label === PINNED_CHAIN) {
return -1;
}
if (b.label === PINNED_CHAIN) {
return 1;
}
return b.nodeCount - a.nodeCount;
}
);
return this.chainsCache;
}
private selectedColumns(settings: State.Settings): Column[] {
return Row.columns.filter(
({ setting }) => setting == null || settings[setting]
);
}
private getComparator(sortBy: Maybe<number>): Compare<Node> {
const columns = this.state.selectedColumns;
if (sortBy != null) {
const [index, rev] = sortBy < 0 ? [~sortBy, -1] : [sortBy, 1];
const column = columns[index];
if (column != null && column.sortBy) {
const key = column.sortBy;
return (a, b) => {
const aKey = key(a);
const bKey = key(b);
if (aKey < bKey) {
return -1 * rev;
} else if (aKey > bKey) {
return 1 * rev;
} else {
return Node.compare(a, b);
}
};
}
}
return Node.compare;
}
}
+526
View File
@@ -0,0 +1,526 @@
import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from './common';
import {
State,
Update,
Node,
UpdateBound,
ChainData,
PINNED_CHAIN,
} from './state';
import { PersistentSet } from './persist';
import { getHashData, setHashData } from './utils';
import { AfgHandling } from './AfgHandling';
import { VIS_AUTHORITIES_LIMIT } from './components/Consensus';
import { Column } from './components/List';
import { ACTIONS } from './common/feed';
const TIMEOUT_BASE = (1000 * 5) as Types.Milliseconds; // 5 seconds
const TIMEOUT_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes
export class Connection {
public static async create(
pins: PersistentSet<Types.NodeName>,
update: Update
): Promise<Connection> {
return new Connection(await Connection.socket(), update, pins);
}
private static readonly utf8decoder = new TextDecoder('utf-8');
private static readonly address =
window.location.protocol === 'https:'
? `wss://${window.location.hostname}/feed/`
: `ws://127.0.0.1:8000/feed`;
private static async socket(): Promise<WebSocket> {
let socket = await Connection.trySocket();
let timeout = TIMEOUT_BASE;
while (!socket) {
await sleep(timeout);
timeout = Math.min(timeout * 2, TIMEOUT_MAX) as Types.Milliseconds;
socket = await Connection.trySocket();
}
return socket;
}
private static async trySocket(): Promise<Maybe<WebSocket>> {
return new Promise<Maybe<WebSocket>>((resolve, _) => {
function clean() {
socket.removeEventListener('open', onSuccess);
socket.removeEventListener('close', onFailure);
socket.removeEventListener('error', onFailure);
}
function onSuccess() {
clean();
resolve(socket);
}
function onFailure() {
clean();
resolve(null);
}
const socket = new WebSocket(Connection.address);
socket.binaryType = 'arraybuffer';
socket.addEventListener('open', onSuccess);
socket.addEventListener('error', onFailure);
socket.addEventListener('close', onFailure);
});
}
private pingId = 0;
private pingTimeout: NodeJS.Timer;
private pingSent: Maybe<Types.Timestamp> = null;
private resubscribeTo: Maybe<Types.ChainLabel> = getHashData().chain;
private resubscribeSendFinality: boolean = getHashData().tab === 'consensus';
private socket: WebSocket;
private state: Readonly<State>;
private readonly update: Update;
private readonly pins: PersistentSet<Types.NodeName>;
constructor(
socket: WebSocket,
update: Update,
pins: PersistentSet<Types.NodeName>
) {
this.socket = socket;
this.update = update;
this.pins = pins;
this.bindSocket();
}
public subscribe(chain: Types.ChainLabel) {
if (this.state.subscribed != null && this.state.subscribed !== chain) {
this.state = this.update({
tab: 'list',
});
setHashData({ chain, tab: 'list' });
} else {
setHashData({ chain });
}
this.socket.send(`subscribe:${chain}`);
}
public subscribeConsensus(chain: Types.ChainLabel) {
if (this.state.authorities.length <= VIS_AUTHORITIES_LIMIT) {
setHashData({ chain });
this.resubscribeSendFinality = true;
this.socket.send(`send-finality:${chain}`);
}
}
public resetConsensus() {
this.state = this.update({
consensusInfo: new Array() as Types.ConsensusInfo,
displayConsensusLoadingScreen: true,
authorities: [] as Types.Address[],
authoritySetId: null,
});
}
public unsubscribeConsensus(chain: Types.ChainLabel) {
this.resubscribeSendFinality = true;
this.socket.send(`no-more-finality:${chain}`);
}
public handleMessages = (messages: FeedMessage.Message[]) => {
const { nodes, chains, sortBy, selectedColumns } = this.state;
const ref = nodes.ref();
const updateState: UpdateBound = (state) => {
this.state = this.update(state);
};
const getState = () => this.state;
const afg = new AfgHandling(updateState, getState);
let sortByColumn: Maybe<Column> = null;
if (sortBy != null) {
sortByColumn =
sortBy < 0 ? selectedColumns[~sortBy] : selectedColumns[sortBy];
}
for (const message of messages) {
switch (message.action) {
case ACTIONS.FeedVersion: {
if (message.payload !== VERSION) {
this.state = this.update({ status: 'upgrade-requested' });
this.clean();
// Force reload from the server
setTimeout(() => window.location.reload(true), 3000);
return;
}
break;
}
case ACTIONS.BestBlock: {
const [best, blockTimestamp, blockAverage] = message.payload;
nodes.mutEach((node) => node.newBestBlock());
this.state = this.update({ best, blockTimestamp, blockAverage });
break;
}
case ACTIONS.BestFinalized: {
const [finalized /*, hash */] = message.payload;
this.state = this.update({ finalized });
break;
}
case ACTIONS.AddedNode: {
const [
id,
nodeDetails,
nodeStats,
nodeIO,
nodeHardware,
blockDetails,
location,
connectedAt,
] = message.payload;
const pinned = this.pins.has(nodeDetails[0]);
const node = new Node(
pinned,
id,
nodeDetails,
nodeStats,
nodeIO,
nodeHardware,
blockDetails,
location,
connectedAt
);
nodes.add(node);
break;
}
case ACTIONS.RemovedNode: {
const id = message.payload;
nodes.remove(id);
break;
}
case ACTIONS.StaleNode: {
const id = message.payload;
nodes.mutAndSort(id, (node) => node.setStale(true));
break;
}
case ACTIONS.LocatedNode: {
const [id, lat, lon, city] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateLocation([lat, lon, city]),
sortByColumn === Column.LOCATION
);
break;
}
case ACTIONS.ImportedBlock: {
const [id, blockDetails] = message.payload;
nodes.mutAndSort(id, (node) => node.updateBlock(blockDetails));
break;
}
case ACTIONS.FinalizedBlock: {
const [id, height, hash] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateFinalized(height, hash),
sortByColumn === Column.FINALIZED ||
sortByColumn === Column.FINALIZED_HASH
);
break;
}
case ACTIONS.NodeStats: {
const [id, nodeStats] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateStats(nodeStats),
sortByColumn === Column.PEERS || sortByColumn === Column.TXS
);
break;
}
case ACTIONS.NodeHardware: {
const [id, nodeHardware] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateHardware(nodeHardware),
sortByColumn === Column.CPU ||
sortByColumn === Column.MEM ||
sortByColumn === Column.UPLOAD ||
sortByColumn === Column.DOWNLOAD
);
break;
}
case ACTIONS.NodeIO: {
const [id, nodeIO] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateIO(nodeIO),
sortByColumn === Column.STATE_CACHE ||
sortByColumn === Column.DB_CACHE ||
sortByColumn === Column.DISK_READ ||
sortByColumn === Column.DISK_WRITE
);
break;
}
case ACTIONS.TimeSync: {
this.state = this.update({
timeDiff: (timestamp() - message.payload) as Types.Milliseconds,
});
break;
}
case ACTIONS.AddedChain: {
const [label, nodeCount] = message.payload;
const chain = chains.get(label);
if (chain) {
chain.nodeCount = nodeCount;
} else {
chains.set(label, { label, nodeCount });
}
this.state = this.update({ chains });
break;
}
case ACTIONS.RemovedChain: {
chains.delete(message.payload);
if (this.state.subscribed === message.payload) {
nodes.clear();
this.state = this.update({ subscribed: null, nodes, chains });
this.resetConsensus();
}
break;
}
case ACTIONS.SubscribedTo: {
nodes.clear();
this.state = this.update({ subscribed: message.payload, nodes });
break;
}
case ACTIONS.UnsubscribedFrom: {
if (this.state.subscribed === message.payload) {
nodes.clear();
this.state = this.update({ subscribed: null, nodes });
}
break;
}
case ACTIONS.Pong: {
this.pong(Number(message.payload));
break;
}
case ACTIONS.AfgFinalized: {
const [nodeAddress, finalizedNumber, finalizedHash] = message.payload;
const no = parseInt(String(finalizedNumber), 10) as Types.BlockNumber;
afg.receivedFinalized(nodeAddress, no, finalizedHash);
break;
}
case ACTIONS.AfgReceivedPrevote: {
const [nodeAddress, blockNumber, blockHash, voter] = message.payload;
const no = parseInt(String(blockNumber), 10) as Types.BlockNumber;
afg.receivedPre(nodeAddress, no, voter, 'prevote');
break;
}
case ACTIONS.AfgReceivedPrecommit: {
const [nodeAddress, blockNumber, blockHash, voter] = message.payload;
const no = parseInt(String(blockNumber), 10) as Types.BlockNumber;
afg.receivedPre(nodeAddress, no, voter, 'precommit');
break;
}
case ACTIONS.AfgAuthoritySet: {
const [authoritySetId, authorities] = message.payload;
afg.receivedAuthoritySet(authoritySetId, authorities);
break;
}
default: {
break;
}
}
}
if (nodes.hasChangedSince(ref)) {
this.state = this.update({ nodes });
}
this.autoSubscribe();
};
private bindSocket() {
this.ping();
if (this.state) {
const { nodes } = this.state;
nodes.clear();
}
this.state = this.update({
status: 'online',
});
if (this.state.subscribed) {
this.resubscribeTo = this.state.subscribed;
this.resubscribeSendFinality = this.state.sendFinality;
this.state = this.update({ subscribed: null, sendFinality: false });
}
this.socket.addEventListener('message', this.handleFeedData);
this.socket.addEventListener('close', this.handleDisconnect);
this.socket.addEventListener('error', this.handleDisconnect);
}
private ping = () => {
if (this.pingSent) {
this.handleDisconnect();
return;
}
this.pingId += 1;
this.pingSent = timestamp();
this.socket.send(`ping:${this.pingId}`);
this.pingTimeout = setTimeout(this.ping, 30000);
};
private pong(id: number) {
if (!this.pingSent) {
console.error('Received a pong without sending a ping first');
this.handleDisconnect();
return;
}
if (id !== this.pingId) {
console.error('pingId differs');
this.handleDisconnect();
}
const latency = timestamp() - this.pingSent;
this.pingSent = null;
console.log('latency', latency);
}
private clean() {
clearTimeout(this.pingTimeout);
this.pingSent = null;
this.socket.removeEventListener('message', this.handleFeedData);
this.socket.removeEventListener('close', this.handleDisconnect);
this.socket.removeEventListener('error', this.handleDisconnect);
}
private handleFeedData = (event: MessageEvent) => {
const data =
typeof event.data === 'string'
? ((event.data as any) as FeedMessage.Data)
: ((Connection.utf8decoder.decode(
event.data
) as any) as FeedMessage.Data);
this.handleMessages(FeedMessage.deserialize(data));
};
private autoSubscribe() {
const { subscribed, chains } = this.state;
const { resubscribeTo, resubscribeSendFinality } = this;
if (subscribed) {
return;
}
if (resubscribeTo) {
if (chains.has(resubscribeTo)) {
this.subscribe(resubscribeTo);
if (resubscribeSendFinality) {
this.subscribeConsensus(resubscribeTo);
}
return;
}
}
let topChain: Maybe<ChainData> = null;
for (const chain of chains.values()) {
if (chain.label === PINNED_CHAIN) {
topChain = chain;
break;
}
if (!topChain || chain.nodeCount > topChain.nodeCount) {
topChain = chain;
}
}
if (topChain) {
this.subscribe(topChain.label);
}
}
private handleDisconnect = async () => {
this.state = this.update({ status: 'offline' });
this.resetConsensus();
this.clean();
this.socket.close();
this.socket = await Connection.socket();
this.bindSocket();
};
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 324 KiB

+222
View File
@@ -0,0 +1,222 @@
import { Maybe, Opaque } from './helpers';
export type Compare<T> = (a: T, b: T) => number;
/**
* Insert an item into a sorted array using binary search.
*
* @type {T} item type
* @param {T} item to be inserted
* @param {Array<T>} array to be modified
* @param {(a, b) => number} compare function
*
* @return {number} insertion index
*/
export function sortedInsert<T>(
item: T,
into: Array<T>,
compare: Compare<T>
): number {
if (into.length === 0) {
into.push(item);
return 0;
}
let min = 0;
let max = into.length - 1;
while (min !== max) {
const guess = ((min + max) / 2) | 0;
if (compare(item, into[guess]) < 0) {
max = Math.max(min, guess - 1);
} else {
min = Math.min(max, guess + 1);
}
}
const insert = compare(item, into[min]) <= 0 ? min : min + 1;
into.splice(insert, 0, item);
return insert;
}
/**
* Find an index of an element within a sorted array. This should be substantially
* faster than `indexOf` for large arrays.
*
* @type {T} item type
* @param {T} item to find
* @param {Array<T>} array to look through
* @param {(a, b) => number} compare function
*
* @return {number} index of the element, `-1` if not found
*/
export function sortedIndexOf<T>(
item: T,
within: Array<T>,
compare: Compare<T>
): number {
if (within.length === 0) {
return -1;
}
let min = 0;
let max = within.length - 1;
while (min !== max) {
const guess = ((min + max) / 2) | 0;
const other = within[guess];
if (item === other) {
return guess;
}
const result = compare(item, other);
if (result < 0) {
max = Math.max(min, guess - 1);
} else if (result > 0) {
min = Math.min(max, guess + 1);
} else {
// Equal sort value, but different reference, do value search from min
return within.indexOf(item, min);
}
}
if (item === within[min]) {
return min;
}
return -1;
}
export namespace SortedCollection {
export type StateRef = Opaque<number, 'SortedCollection.StateRef'>;
}
export class SortedCollection<Item extends { id: number }> {
private compare: Compare<Item>;
private map = Array<Maybe<Item>>();
private list = Array<Item>();
private changeRef = 0;
constructor(compare: Compare<Item>) {
this.compare = compare;
}
public setComparator(compare: Compare<Item>) {
this.compare = compare;
this.list = this.map.filter((item) => item != null) as Item[];
this.list.sort(compare);
this.changeRef += 1;
}
public ref(): SortedCollection.StateRef {
return this.changeRef as SortedCollection.StateRef;
}
public add(item: Item) {
if (this.map.length <= item.id) {
// Grow map if item.id would be out of scope
this.map = this.map.concat(
Array<Maybe<Item>>(Math.max(10, 1 + item.id - this.map.length))
);
}
// Remove old item if overriding
this.remove(item.id);
this.map[item.id] = item;
sortedInsert(item, this.list, this.compare);
this.changeRef += 1;
}
public remove(id: number) {
const item = this.map[id];
if (!item) {
return;
}
const index = sortedIndexOf(item, this.list, this.compare);
this.list.splice(index, 1);
this.map[id] = null;
this.changeRef += 1;
}
public get(id: number): Maybe<Item> {
return this.map[id];
}
public sorted(): Array<Item> {
return this.list;
}
public mut(id: number, mutator: (item: Item) => void) {
const item = this.map[id];
if (!item) {
return;
}
mutator(item);
}
public mutAndSort(id: number, mutator: (item: Item) => void) {
const item = this.map[id];
if (!item) {
return;
}
const index = sortedIndexOf(item, this.list, this.compare);
mutator(item);
this.list.splice(index, 1);
const newIndex = sortedInsert(item, this.list, this.compare);
if (newIndex !== index) {
this.changeRef += 1;
}
}
public mutAndMaybeSort(
id: number,
mutator: (item: Item) => void,
sort: boolean
) {
if (sort) {
this.mutAndSort(id, mutator);
} else {
this.mut(id, mutator);
}
}
public mutEach(mutator: (item: Item) => void) {
this.list.forEach(mutator);
}
public mutEachAndSort(mutator: (item: Item) => void) {
this.list.forEach(mutator);
this.list.sort(this.compare);
}
public clear() {
this.map = [];
this.list = [];
this.changeRef += 1;
}
public hasChangedSince(ref: SortedCollection.StateRef): boolean {
return this.changeRef > ref;
}
}
+249
View File
@@ -0,0 +1,249 @@
import { Maybe } from './helpers';
import { stringify, parse, Stringified } from './stringify';
import {
FeedVersion,
Address,
Latitude,
Longitude,
City,
NodeId,
NodeCount,
NodeDetails,
NodeStats,
NodeIO,
NodeHardware,
NodeLocation,
BlockNumber,
BlockHash,
BlockDetails,
Timestamp,
Milliseconds,
ChainLabel,
AuthoritySetInfo,
} from './types';
export const ACTIONS = {
FeedVersion: 0x00 as 0x00,
BestBlock: 0x01 as 0x01,
BestFinalized: 0x02 as 0x02,
AddedNode: 0x03 as 0x03,
RemovedNode: 0x04 as 0x04,
LocatedNode: 0x05 as 0x05,
ImportedBlock: 0x06 as 0x06,
FinalizedBlock: 0x07 as 0x07,
NodeStats: 0x08 as 0x08,
NodeHardware: 0x09 as 0x09,
TimeSync: 0x0a as 0x0a,
AddedChain: 0x0b as 0x0b,
RemovedChain: 0x0c as 0x0c,
SubscribedTo: 0x0d as 0x0d,
UnsubscribedFrom: 0x0e as 0x0e,
Pong: 0x0f as 0x0f,
AfgFinalized: 0x10 as 0x10,
AfgReceivedPrevote: 0x11 as 0x11,
AfgReceivedPrecommit: 0x12 as 0x12,
AfgAuthoritySet: 0x13 as 0x13,
StaleNode: 0x14 as 0x14,
NodeIO: 0x15 as 0x15,
};
export type Action = typeof ACTIONS[keyof typeof ACTIONS];
export type Payload = Message['payload'];
export namespace Variants {
export interface MessageBase {
action: Action;
}
export interface FeedVersionMessage extends MessageBase {
action: typeof ACTIONS.FeedVersion;
payload: FeedVersion;
}
export interface BestBlockMessage extends MessageBase {
action: typeof ACTIONS.BestBlock;
payload: [BlockNumber, Timestamp, Maybe<Milliseconds>];
}
export interface BestFinalizedBlockMessage extends MessageBase {
action: typeof ACTIONS.BestFinalized;
payload: [BlockNumber, BlockHash];
}
export interface AddedNodeMessage extends MessageBase {
action: typeof ACTIONS.AddedNode;
payload: [
NodeId,
NodeDetails,
NodeStats,
NodeIO,
NodeHardware,
BlockDetails,
Maybe<NodeLocation>,
Timestamp
];
}
export interface RemovedNodeMessage extends MessageBase {
action: typeof ACTIONS.RemovedNode;
payload: NodeId;
}
export interface LocatedNodeMessage extends MessageBase {
action: typeof ACTIONS.LocatedNode;
payload: [NodeId, Latitude, Longitude, City];
}
export interface ImportedBlockMessage extends MessageBase {
action: typeof ACTIONS.ImportedBlock;
payload: [NodeId, BlockDetails];
}
export interface FinalizedBlockMessage extends MessageBase {
action: typeof ACTIONS.FinalizedBlock;
payload: [NodeId, BlockNumber, BlockHash];
}
export interface NodeStatsMessage extends MessageBase {
action: typeof ACTIONS.NodeStats;
payload: [NodeId, NodeStats];
}
export interface NodeHardwareMessage extends MessageBase {
action: typeof ACTIONS.NodeHardware;
payload: [NodeId, NodeHardware];
}
export interface NodeIOMessage extends MessageBase {
action: typeof ACTIONS.NodeIO;
payload: [NodeId, NodeIO];
}
export interface TimeSyncMessage extends MessageBase {
action: typeof ACTIONS.TimeSync;
payload: Timestamp;
}
export interface AddedChainMessage extends MessageBase {
action: typeof ACTIONS.AddedChain;
payload: [ChainLabel, NodeCount];
}
export interface RemovedChainMessage extends MessageBase {
action: typeof ACTIONS.RemovedChain;
payload: ChainLabel;
}
export interface SubscribedToMessage extends MessageBase {
action: typeof ACTIONS.SubscribedTo;
payload: ChainLabel;
}
export interface UnsubscribedFromMessage extends MessageBase {
action: typeof ACTIONS.UnsubscribedFrom;
payload: ChainLabel;
}
export interface PongMessage extends MessageBase {
action: typeof ACTIONS.Pong;
payload: string; // just echo whatever `ping` sent
}
export interface AfgFinalizedMessage extends MessageBase {
action: typeof ACTIONS.AfgFinalized;
payload: [Address, BlockNumber, BlockHash];
}
export interface AfgAuthoritySet extends MessageBase {
action: typeof ACTIONS.AfgAuthoritySet;
payload: AuthoritySetInfo;
}
export interface AfgReceivedPrecommit extends MessageBase {
action: typeof ACTIONS.AfgReceivedPrecommit;
payload: [Address, BlockNumber, BlockHash, Address];
}
export interface AfgReceivedPrevote extends MessageBase {
action: typeof ACTIONS.AfgReceivedPrevote;
payload: [Address, BlockNumber, BlockHash, Address];
}
export interface StaleNodeMessage extends MessageBase {
action: typeof ACTIONS.StaleNode;
payload: NodeId;
}
}
export type Message =
| Variants.FeedVersionMessage
| Variants.BestBlockMessage
| Variants.BestFinalizedBlockMessage
| Variants.AddedNodeMessage
| Variants.RemovedNodeMessage
| Variants.LocatedNodeMessage
| Variants.ImportedBlockMessage
| Variants.FinalizedBlockMessage
| Variants.NodeStatsMessage
| Variants.NodeHardwareMessage
| Variants.TimeSyncMessage
| Variants.AddedChainMessage
| Variants.RemovedChainMessage
| Variants.SubscribedToMessage
| Variants.UnsubscribedFromMessage
| Variants.AfgFinalizedMessage
| Variants.AfgReceivedPrevote
| Variants.AfgReceivedPrecommit
| Variants.AfgAuthoritySet
| Variants.StaleNodeMessage
| Variants.PongMessage
| Variants.NodeIOMessage;
/**
* Data type to be sent to the feed. Passing through strings means we can only serialize once,
* no matter how many feed clients are listening in.
*/
export interface SquashedMessages extends Array<Action | Payload> {}
export type Data = Stringified<SquashedMessages>;
/**
* Serialize an array of `Message`s to a single JSON string.
*
* All messages are squashed into a single array of alternating opcodes and payloads.
*
* Action `string`s are converted to opcodes using the `actionToCode` mapping.
*/
export function serialize(messages: Array<Message>): Data {
const squashed: SquashedMessages = new Array(messages.length * 2);
let index = 0;
messages.forEach((message) => {
const { action, payload } = message;
squashed[index++] = action;
squashed[index++] = payload;
});
return stringify(squashed);
}
/**
* Deserialize data to an array of `Message`s.
*/
export function deserialize(data: Data): Array<Message> {
const json = parse(data);
if (!Array.isArray(json) || json.length === 0 || json.length % 2 !== 0) {
throw new Error('Invalid FeedMessage.Data');
}
const messages = new Array<Message>(json.length / 2);
for (const index of messages.keys()) {
const [action, payload] = json.slice(index * 2);
messages[index] = { action, payload } as Message;
}
return messages;
}
+118
View File
@@ -0,0 +1,118 @@
import { Milliseconds, Timestamp } from './types';
/**
* PhantomData akin to Rust, because sometimes you need to be smarter than
* the compiler.
*/
export abstract class PhantomData<P> {
public __PHANTOM__: P;
}
/**
* Opaque type, similar to `opaque type` in Flow, or new types in Rust/C.
* These should be produced only by manually casting `t as Opaque<T, P>`.
*
* `P` can be anything as it's never actually used. Using strings is okay:
*
* ```
* type MyType = Opaque<number, 'MyType'>;
* ```
*/
export type Opaque<T, P> = T & PhantomData<P>;
/**
* Just a readable shorthand for null-ish-able types, akin to `T?` in Flow.
*/
export type Maybe<T> = T | null | undefined;
/**
* Asynchronous sleep
*/
export function sleep(time: Milliseconds): Promise<void> {
return new Promise<void>((resolve, _reject) => {
setTimeout(() => resolve(), time);
});
}
export const timestamp = Date.now as () => Timestamp;
export function noop() {}
/**
* Keep track of last N numbers pushed onto internal stack.
* Provides means to get an average of said numbers.
*/
export class NumStats<T extends number> {
private readonly stack: Array<T>;
private readonly history: number;
private index = 0;
constructor(history: number) {
if (history < 1) {
throw new Error('Must track at least one number');
}
this.history = history;
this.stack = new Array(history);
}
public push(val: T) {
this.stack[this.index++ % this.history] = val;
}
/**
* Get average value of all values on the stack.
*
* @return {T} average value
*/
public average(): T {
if (this.index === 0) {
return 0 as T;
}
const list = this.nonEmpty();
let sum = 0;
for (const n of list as Array<number>) {
sum += n;
}
return (sum / list.length) as T;
}
/**
* Get average value of all values of the stack after filtering
* out a number of highest and lowest values
*
* @param {number} extremes number of high/low values to ignore
* @return {T} average value
*/
public averageWithoutExtremes(extremes: number): T {
if (this.index === 0) {
return 0 as T;
}
const list = this.nonEmpty();
const count = list.length - extremes * 2;
if (count < 1) {
// Not enough entries to remove desired number of extremes,
// fall back to regular average
return this.average();
}
let sum = 0;
for (const n of list.sort((a, b) => a - b).slice(extremes, -extremes)) {
sum += n;
}
return (sum / count) as T;
}
private nonEmpty(): Readonly<Array<number>> {
return this.index < this.history
? this.stack.slice(0, this.index)
: this.stack;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Opaque } from './helpers';
/**
* Unique type-constrained Id number.
*/
export type Id<T> = Opaque<number, T>;
/**
* Higher order function producing new auto-incremented `Id`s.
*/
export function idGenerator<I extends Id<any>>(): () => I {
let current = 0;
return () => current++ as I;
}
+12
View File
@@ -0,0 +1,12 @@
export * from './helpers';
export * from './id';
export * from './stringify';
export * from './SortedCollection';
import * as Types from './types';
import * as FeedMessage from './feed';
export { Types, FeedMessage };
// Increment this if breaking changes were made to types in `feed.ts`
export const VERSION: Types.FeedVersion = 29 as Types.FeedVersion;
+84
View File
@@ -0,0 +1,84 @@
export function* map<T, U>(
iter: IterableIterator<T>,
fn: (item: T) => U
): IterableIterator<U> {
for (const item of iter) {
yield fn(item);
}
}
export function* chain<T>(
a: IterableIterator<T>,
b: IterableIterator<T>
): IterableIterator<T> {
yield* a;
yield* b;
}
export function* zip<T, U>(
a: IterableIterator<T>,
b: IterableIterator<U>
): IterableIterator<[T, U]> {
let itemA = a.next();
let itemB = b.next();
while (!itemA.done && !itemB.done) {
yield [itemA.value, itemB.value];
itemA = a.next();
itemB = b.next();
}
}
export function* take<T>(
iter: IterableIterator<T>,
n: number
): IterableIterator<T> {
for (const item of iter) {
if (n-- === 0) {
return;
}
yield item;
}
}
export function skip<T>(
iter: IterableIterator<T>,
n: number
): IterableIterator<T> {
while (n-- !== 0 && !iter.next().done) {}
return iter;
}
export function reduce<T, R>(
iter: IterableIterator<T>,
fn: (accu: R, item: T) => R,
accumulator: R
): R {
for (const item of iter) {
accumulator = fn(accumulator, item);
}
return accumulator;
}
export function join(
iter: IterableIterator<{ toString: () => string }>,
glue: string
): string {
const first = iter.next();
if (first.done) {
return '';
}
let result = first.value.toString();
for (const item of iter) {
result += glue + item;
}
return result;
}
+8
View File
@@ -0,0 +1,8 @@
export abstract class Stringified<T> {
public __PHANTOM__: T;
}
export const parse = (JSON.parse as any) as <T>(val: Stringified<T>) => T;
export const stringify = (JSON.stringify as any) as <T>(
val: T
) => Stringified<T>;
+95
View File
@@ -0,0 +1,95 @@
import { Opaque, Maybe } from './helpers';
import { Id } from './id';
export type FeedVersion = Opaque<number, 'FeedVersion'>;
export type ChainLabel = Opaque<string, 'ChainLabel'>;
export type FeedId = Id<'Feed'>;
export type NodeId = Id<'Node'>;
export type NodeName = Opaque<string, 'NodeName'>;
export type NodeImplementation = Opaque<string, 'NodeImplementation'>;
export type NodeVersion = Opaque<string, 'NodeVersion'>;
export type BlockNumber = Opaque<number, 'BlockNumber'>;
export type BlockHash = Opaque<string, 'BlockHash'>;
export type Address = Opaque<string, 'Address'>;
export type Milliseconds = Opaque<number, 'Milliseconds'>;
export type Timestamp = Opaque<Milliseconds, 'Timestamp'>;
export type PropagationTime = Opaque<Milliseconds, 'PropagationTime'>;
export type NodeCount = Opaque<number, 'NodeCount'>;
export type PeerCount = Opaque<number, 'PeerCount'>;
export type TransactionCount = Opaque<number, 'TransactionCount'>;
export type Latitude = Opaque<number, 'Latitude'>;
export type Longitude = Opaque<number, 'Longitude'>;
export type City = Opaque<string, 'City'>;
export type MemoryUse = Opaque<number, 'MemoryUse'>;
export type CPUUse = Opaque<number, 'CPUUse'>;
export type Bytes = Opaque<number, 'Bytes'>;
export type BytesPerSecond = Opaque<number, 'BytesPerSecond'>;
export type NetworkId = Opaque<string, 'NetworkId'>;
export type NetworkState = Opaque<string | object, 'NetworkState'>;
export type BlockDetails = [
BlockNumber,
BlockHash,
Milliseconds,
Timestamp,
Maybe<PropagationTime>
];
export type NodeDetails = [
NodeName,
NodeImplementation,
NodeVersion,
Maybe<Address>,
Maybe<NetworkId>
];
export type NodeStats = [PeerCount, TransactionCount];
export type NodeIO = [
Array<Bytes>,
Array<Bytes>,
Array<BytesPerSecond>,
Array<BytesPerSecond>
];
export type NodeHardware = [
Array<MemoryUse>,
Array<CPUUse>,
Array<BytesPerSecond>,
Array<BytesPerSecond>,
Array<Timestamp>
];
export type NodeLocation = [Latitude, Longitude, City];
export interface Authority {
Address: Address;
NodeId: Maybe<NodeId>;
Name: Maybe<NodeName>;
}
export declare type Authorities = Array<Address>;
export declare type AuthoritySetId = Opaque<number, 'AuthoritySetId'>;
export declare type AuthoritySetInfo = [
AuthoritySetId,
Authorities,
Address,
BlockNumber,
BlockHash
];
export declare type ConsensusItem = [BlockNumber, ConsensusView];
export declare type ConsensusInfo = Array<ConsensusItem>;
export declare type ConsensusView = Map<Address, ConsensusState>;
export declare type ConsensusState = Map<Address, ConsensusDetail>;
export interface ConsensusDetail {
Precommit: Precommit;
ImplicitPrecommit: ImplicitPrecommit;
Prevote: Prevote;
ImplicitPrevote: ImplicitPrevote;
ImplicitPointer: ImplicitPointer;
Finalized: ImplicitFinalized;
ImplicitFinalized: Finalized;
FinalizedHash: BlockHash;
FinalizedHeight: BlockNumber;
}
export declare type Precommit = Opaque<boolean, 'Precommit'>;
export declare type Prevote = Opaque<boolean, 'Prevote'>;
export declare type Finalized = Opaque<boolean, 'Finalized'>;
export declare type ImplicitPrecommit = Opaque<boolean, 'ImplicitPrecommit'>;
export declare type ImplicitPrevote = Opaque<boolean, 'ImplicitPrevote'>;
export declare type ImplicitFinalized = Opaque<boolean, 'ImplicitFinalized'>;
export declare type ImplicitPointer = Opaque<BlockNumber, 'ImplicitPointer'>;
+90
View File
@@ -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>
);
}
}
+61
View File
@@ -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;
}
+59
View File
@@ -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();
}
}
+34
View File
@@ -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;
}
+147
View File
@@ -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 });
};
}
+24
View File
@@ -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;
}
+37
View File
@@ -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);
};
}
+2
View File
@@ -0,0 +1,2 @@
export * from './Chain';
export * from './Tab';
+75
View File
@@ -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;
}
+71
View File
@@ -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&hellip;
</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'}
>
&nbsp;
</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>&nbsp;</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">&nbsp;</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';
+38
View File
@@ -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;
}
+125
View File
@@ -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;
};
}
}
+14
View File
@@ -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;
}
+36
View File
@@ -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}
/>
);
}
}
+536
View File
@@ -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);
}
};
}
+19
View File
@@ -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;
}
+156
View File
@@ -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);
}
+91
View File
@@ -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);
}
+138
View File
@@ -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 });
};
}
+38
View File
@@ -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
);
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './Column';
export * from './List';
export * from './Truncate';
export * from './Row';
export * from './HeaderCell';
+126
View File
@@ -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;
}
+180
View File
@@ -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 });
};
}
+11
View File
@@ -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;
}
+137
View File
@@ -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 });
};
}
+2
View File
@@ -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>
);
}
}
+217
View File
@@ -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';
+6
View File
@@ -0,0 +1,6 @@
.Sparkline {
fill: currentcolor;
fill-opacity: 0.35;
stroke: currentcolor;
margin: 0 -1px -3px -1px;
}
+91
View File
@@ -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);
};
}
+37
View File
@@ -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;
}
+21
View File
@@ -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>
);
}
+90
View File
@@ -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;
}
}
+106
View File
@@ -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 });
};
}
+15
View File
@@ -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';
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M13 2H1v2h12V2zM0 4a1 1 0 0 0 1 1v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H1a1 1 0 0 0-1 1v2zm2 1h10v9H2V5zm2 3h6V7H4v1z"/></svg>

After

Width:  |  Height:  |  Size: 265 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M7 7V3H3v4H0l5 6 5-6H7z"/></svg>

After

Width:  |  Height:  |  Size: 144 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M6 3L0 8l6 5v-3h4V6H6V3z"/></svg>

After

Width:  |  Height:  |  Size: 145 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M10 8L4 3v3H0v4h4v3l6-5z"/></svg>

After

Width:  |  Height:  |  Size: 145 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="16" viewBox="0 0 6 16"><path fill-rule="evenodd" d="M4 7V5H2v2H0l3 4 3-4H4z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="16" viewBox="0 0 6 16"><path fill-rule="evenodd" d="M4 7V5L0 8l4 3V9h2V7H4z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="16" viewBox="0 0 6 16"><path fill-rule="evenodd" d="M6 8L2 5v2H0v2h2v2l4-3z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="16" viewBox="0 0 6 16"><path fill-rule="evenodd" d="M3 5L0 9h2v2h2V9h2L3 5z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M5 3L0 9h3v4h4V9h3L5 3z"/></svg>

After

Width:  |  Height:  |  Size: 144 B

+1
View File
@@ -0,0 +1 @@
declare module '*.svg';
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M14.38 14.59L11 7V3h1V2H3v1h1v4L.63 14.59A1 1 0 0 0 1.54 16h11.94c.72 0 1.2-.75.91-1.41h-.01zM3.75 10L5 7V3h5v4l1.25 3h-7.5zM8 8h1v1H8V8zM7 7H6V6h1v1zm0-3h1v1H7V4zm0-3H6V0h1v1z"/></svg>

After

Width:  |  Height:  |  Size: 297 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M13.99 11.991v1H0v-1l.73-.58c.769-.769.809-2.547 1.189-4.416.77-3.767 4.077-4.996 4.077-4.996 0-.55.45-1 .999-1 .55 0 1 .45 1 1 0 0 3.387 1.229 4.156 4.996.38 1.879.42 3.657 1.19 4.417l.659.58h-.01zM6.995 15.99c1.11 0 1.999-.89 1.999-1.999H4.996c0 1.11.89 1.999 1.999 1.999z"/></svg>

After

Width:  |  Height:  |  Size: 395 B

+1
View File
@@ -0,0 +1 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="none"><path d="M20 0C9 0 0 9 0 20V80C0 85.5 2.2 90.5 5.9 94.1L75 25H100L50 100H80C91 100 100 91 100 80V25 20C100 9 91 0 80 0H20Z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M1 2h3.83c2.48 0 4.3.75 4.3 2.95 0 1.14-.63 2.23-1.67 2.61v.06c1.33.3 2.3 1.23 2.3 2.86 0 2.39-1.97 3.52-4.61 3.52H1V2zm3.66 4.95c1.67 0 2.38-.66 2.38-1.69 0-1.17-.78-1.61-2.34-1.61H3.13v3.3h1.53zm.27 5.39c1.77 0 2.75-.64 2.75-1.98 0-1.27-.95-1.81-2.75-1.81h-1.8v3.8h1.8v-.01z"/></svg>

After

Width:  |  Height:  |  Size: 397 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M3 5h4v1H3V5zm0 3h4V7H3v1zm0 2h4V9H3v1zm11-5h-4v1h4V5zm0 2h-4v1h4V7zm0 2h-4v1h4V9zm2-6v9c0 .55-.45 1-1 1H9.5l-1 1-1-1H2c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h5.5l1 1 1-1H15c.55 0 1 .45 1 1zm-8 .5L7.5 3H2v9h6V3.5zm7-.5H9.5l-.5.5V12h6V3z"/></svg>

After

Width:  |  Height:  |  Size: 352 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M9 0H1C.27 0 0 .27 0 1v15l5-3.09L10 16V1c0-.73-.27-1-1-1zm-.78 4.25L6.36 5.61l.72 2.16c.06.22-.02.28-.2.17L5 6.6 3.12 7.94c-.19.11-.25.05-.2-.17l.72-2.16-1.86-1.36c-.17-.16-.14-.23.09-.23l2.3-.03.7-2.16h.25l.7 2.16 2.3.03c.23 0 .27.08.09.23h.01z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M9 4V3c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1v1H1c-.55 0-1 .45-1 1v8c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1H9zM6 3h2v1H6V3zm7 6H8v1H6V9H1V5h1v3h10V5h1v4z"/></svg>

After

Width:  |  Height:  |  Size: 283 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 9H8c.55 0 1-.45 1-1V7c0-.55-.45-1-1-1H7c-.55 0-1 .45-1 1v1c0 .55.45 1 1 1H6c-.55 0-1 .45-1 1v2h1v3c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-3h1v-2c0-.55-.45-1-1-1zM7 7h1v1H7V7zm2 4H8v4H7v-4H6v-1h3v1zm2.09-3.5c0-1.98-1.61-3.59-3.59-3.59A3.593 3.593 0 0 0 4 8.31v1.98c-.61-.77-1-1.73-1-2.8 0-2.48 2.02-4.5 4.5-4.5S12 5.01 12 7.49c0 1.06-.39 2.03-1 2.8V8.31c.06-.27.09-.53.09-.81zm3.91 0c0 2.88-1.63 5.38-4 6.63v-1.05a6.553 6.553 0 0 0 3.09-5.58A6.59 6.59 0 0 0 7.5.91 6.59 6.59 0 0 0 .91 7.5c0 2.36 1.23 4.42 3.09 5.58v1.05A7.497 7.497 0 0 1 7.5 0C11.64 0 15 3.36 15 7.5z"/></svg>

After

Width:  |  Height:  |  Size: 686 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M5 3h1v1H5V3zM3 3h1v1H3V3zM1 3h1v1H1V3zm12 10H1V5h12v8zm0-9H7V3h6v1zm1-1c0-.55-.45-1-1-1H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V3z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11 10h3V9h-3V8l3.17-1.03-.34-.94L11 7V6c0-.55-.45-1-1-1V4c0-.48-.36-.88-.83-.97L10.2 2H12V1H9.8l-2 2h-.59L5.2 1H3v1h1.8l1.03 1.03C5.36 3.12 5 3.51 5 4v1c-.55 0-1 .45-1 1v1l-2.83-.97-.34.94L4 8v1H1v1h3v1L.83 12.03l.34.94L4 12v1c0 .55.45 1 1 1h1l1-1V6h1v7l1 1h1c.55 0 1-.45 1-1v-1l2.83.97.34-.94L11 11v-1zM9 5H6V4h3v1z"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M13 2h-1v1.5c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5V2H6v1.5c0 .28-.22.5-.5.5h-2c-.28 0-.5-.22-.5-.5V2H2c-.55 0-1 .45-1 1v11c0 .55.45 1 1 1h11c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm0 12H2V5h11v9zM5 3H4V1h1v2zm6 0h-1V1h1v2zM6 7H5V6h1v1zm2 0H7V6h1v1zm2 0H9V6h1v1zm2 0h-1V6h1v1zM4 9H3V8h1v1zm2 0H5V8h1v1zm2 0H7V8h1v1zm2 0H9V8h1v1zm2 0h-1V8h1v1zm-8 2H3v-1h1v1zm2 0H5v-1h1v1zm2 0H7v-1h1v1zm2 0H9v-1h1v1zm2 0h-1v-1h1v1zm-8 2H3v-1h1v1zm2 0H5v-1h1v1zm2 0H7v-1h1v1zm2 0H9v-1h1v1z"/></svg>

After

Width:  |  Height:  |  Size: 588 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256.67 322"><path fill="currentColor" d="M190.29,165.89a10.74,10.74,0,0,0,11.51-1.44l51-42.18a10.7,10.7,0,0,0,3.88-8.27V83.45A71.5,71.5,0,0,0,198,13.1L132.66,1.18A71.26,71.26,0,0,0,74,16.61,72.81,72.81,0,0,0,54.75,42.47,71.43,71.43,0,0,0,0,111.89V239.21a71.36,71.36,0,0,0,58.59,70.22l65.57,12a44.57,44.57,0,0,0,8.11.6l2.41,0,2.38,0c17.36,0,33.14-5.51,45.66-15.95A71.75,71.75,0,0,0,202,280.65c17.36-4.22,32.71-15.3,43.23-31.21a71,71,0,0,0,11.43-39.37v-42a12.12,12.12,0,0,0-19.84-9.35l-48.68,40.22a12.5,12.5,0,0,0-4.07,9.25v42.38a47.53,47.53,0,0,1-.56,7.18l-1.22,0h-.11a25.72,25.72,0,0,1-5.51-.21l-65.34-11.92A47.19,47.19,0,0,1,72.6,199.21V72.14a46.65,46.65,0,0,1,.58-7.31,46.88,46.88,0,0,1,6.7.73l65.55,11.35a47.09,47.09,0,0,1,38.65,46.33V156.1A10.72,10.72,0,0,0,190.29,165.89ZM149.81,53l-65.59-12L83.88,41h-.06A47.13,47.13,0,0,1,128.3,25.09L193.64,37a47.19,47.19,0,0,1,38.73,46.43v24.23l-24,19.82v-4.26A71.35,71.35,0,0,0,149.81,53ZM107,269.56l65.34,11.93.26,0a39.21,39.21,0,0,1-5.43,5.82,46.94,46.94,0,0,1-38.6,10.16L63,285.54a47.46,47.46,0,0,1-38.65-47V111.89A47.06,47.06,0,0,1,41.24,75.72a47.59,47.59,0,0,1,7.07-4.86c0,.43,0,.86,0,1.28V199.21A71.5,71.5,0,0,0,107,269.56Zm101.4-17.94c0-.35,0-.71,0-1.06V213.74l24-19.83v16.61a47,47,0,0,1-17,36.25A46.28,46.28,0,0,1,208.37,251.62Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg"><style>.a{opacity:0.25;}.b{opacity:0.75;}.c{opacity:0.5;}</style><g fill="currentColor"><g transform="translate(-239 -305)translate(239 305)"><path d="M49.8 15.5C49.5 15.2 48.8 15.2 48.5 15.5L40.9 23.1C40.5 23.5 40.5 24.1 40.9 24.5L59.8 43.3C64.4 47.9 64.5 55.6 59.8 60.1 55.3 64.5 48 64.5 43.5 60L16 32.5C11.5 28 11.5 20.7 15.9 16.2 20.4 11.5 28.1 11.6 32.7 16.2L35.4 19C35.8 19.3 36.4 19.3 36.8 19L44.4 11.4C44.7 11 44.7 10.4 44.4 10L41.4 7.1C31.9-2.4 16.6-2.4 7.1 7.1 -2.4 16.6-2.4 32 7.1 41.4L34.6 68.9C44 78.4 59.4 78.4 68.9 68.9 78.4 59.4 78.4 44.1 68.9 34.6L49.8 15.5Z" class="a"/><path d="M112.9 7.1C103.4-2.4 88.1-2.4 78.6 7.1L60 25.7 68.9 34.6C68.9 34.6 68.9 34.6 68.9 34.6L87.5 16C92 11.5 99.4 11.5 104 16 108.5 20.6 108.5 28 104 32.5L101 35.4C100.7 35.8 100.7 36.4 101 36.8L108.6 44.4C109 44.7 109.6 44.7 110 44.4L112.9 41.4C122.4 32 122.4 16.6 112.9 7.1" class="b"/><path d="M96.9 40.9C96.5 40.6 95.9 40.6 95.6 40.9L76.5 60C75.7 60.8 74.7 61.5 73.7 62.1 69.3 64.4 63.7 63.7 60 60 60 60 60 59.9 60 59.9 55.5 55.4 55.5 48.1 60 43.5L51.1 34.6C43.7 42 42.1 53.1 46.3 62.1 47.5 64.5 49.1 66.9 51.1 68.9 53.7 71.5 56.8 73.4 60 74.6 62.7 75.5 65.5 76 68.3 76 74.5 76 80.7 73.6 85.4 68.9L104.5 49.9C104.9 49.5 104.9 48.9 104.5 48.5L96.9 40.9Z" class="b"/><path d="M112.9 78.6L94.3 60 85.4 68.9 104 87.5C108.5 92 108.5 99.3 104.1 103.9 99.6 108.6 91.9 108.4 87.3 103.8L84.6 101.1C84.2 100.7 83.6 100.7 83.2 101.1L75.6 108.6C75.3 109 75.3 109.6 75.6 110L78.3 112.6C87.8 122.1 103.6 122.4 113 112.8 117.7 108.1 120 101.9 120 95.8 120 89.6 117.6 83.3 112.9 78.6" class="a"/><path d="M79.1 97C79.5 96.6 79.5 96 79.1 95.6L60 76.5C59.2 75.7 58.5 74.7 58 73.7 55.7 69.3 56.4 63.8 60 60.1L60.1 60C64.6 55.5 71.9 55.5 76.5 60L85.4 51.1C78 43.7 66.9 42.1 58 46.3 55.5 47.5 53.2 49.1 51.1 51.1 48.5 53.7 46.6 56.8 45.5 60 44.5 62.7 44 65.5 44 68.3 44 74.5 46.4 80.7 51.1 85.5L70.2 104.5C70.5 104.9 71.2 104.9 71.5 104.5L79.1 97Z" class="a"/><path d="M74.6 60.1C74.3 59.4 74 58.6 73.7 57.9 72.5 55.5 70.9 53.1 68.9 51.1 66.8 49.1 64.5 47.5 62.1 46.3 61.4 46 60.7 45.7 60 45.4 51.4 42.3 41.5 44.2 34.6 51.1L43.5 60C44.4 59.1 45.3 58.5 46.3 57.9 50.7 55.6 56.3 56.3 60 60L60 60.1C63.7 63.8 64.3 69.4 62 73.8 61.5 74.7 60.8 75.6 60 76.5L68.9 85.4C75.7 78.5 77.6 68.6 74.6 60.1" class="c"/><path d="M24.4 79.1L34.6 68.9 25.7 60 15.5 70.2C15.1 70.5 15.1 71.2 15.5 71.5L23.1 79.1C23.4 79.5 24 79.5 24.4 79.1" class="c"/><g transform="translate(0 75)"><mask fill="white"><polygon points="0 0.4 60 0.4 60 45 0 45"/></mask><path d="M32.5 29C28 33.5 20.6 33.5 16 29 11.5 24.4 11.5 17 16 12.5L18.9 9.6C19.3 9.2 19.3 8.6 18.9 8.2L11.4 0.7C11 0.3 10.4 0.3 10 0.7L7.1 3.6C-2.4 13-2.4 28.4 7.1 37.9 11.8 42.6 18.1 45 24.3 45 30.5 45 36.7 42.6 41.4 37.9L60 19.3 51.1 10.4 32.5 29Z" mask="url(#mask-2)" class="c"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="far" data-icon="check-square" class="svg-inline--fa fa-check-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V80c0-26.51-21.49-48-48-48zm0 400H48V80h352v352zm-35.864-241.724L191.547 361.48c-4.705 4.667-12.303 4.637-16.97-.068l-90.781-91.516c-4.667-4.705-4.637-12.303.069-16.971l22.719-22.536c4.705-4.667 12.303-4.637 16.97.069l59.792 60.277 141.352-140.216c4.705-4.667 12.303-4.637 16.97.068l22.536 22.718c4.667 4.706 4.637 12.304-.068 16.971z"></path></svg>

After

Width:  |  Height:  |  Size: 646 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="fas" data-icon="check-square" class="svg-inline--fa fa-check-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 480H48c-26.51 0-48-21.49-48-48V80c0-26.51 21.49-48 48-48h352c26.51 0 48 21.49 48 48v352c0 26.51-21.49 48-48 48zm-204.686-98.059l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.248-16.379-6.249-22.628 0L184 302.745l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.25 16.379 6.25 22.628.001z"></path></svg>

After

Width:  |  Height:  |  Size: 605 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16"><path fill-rule="evenodd" d="M12 5l-8 8-4-4 1.5-1.5L4 10l6.5-6.5L12 5z"/></svg>

After

Width:  |  Height:  |  Size: 162 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M16 8.5l-6 6-3-3L8.5 10l1.5 1.5L14.5 7 16 8.5zM5.7 12.2l.8.8H2c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h7c.55 0 1 .45 1 1v6.5l-.8-.8c-.39-.39-1.03-.39-1.42 0L5.7 10.8a.996.996 0 0 0 0 1.41v-.01zM4 4h5V3H4v1zm0 2h5V5H4v1zm0 2h3V7H4v1zM3 9H2v1h1V9zm0-2H2v1h1V7zm0-2H2v1h1V5zm0-2H2v1h1V3z"/></svg>

After

Width:  |  Height:  |  Size: 399 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M5 11L0 6l1.5-1.5L5 8.25 8.5 4.5 10 6l-5 5z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="16" viewBox="0 0 8 16"><path fill-rule="evenodd" d="M5.5 3L7 4.5 3.25 8 7 11.5 5.5 13l-5-5 5-5z"/></svg>

After

Width:  |  Height:  |  Size: 162 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="16" viewBox="0 0 8 16"><path fill-rule="evenodd" d="M7.5 8l-5 5L1 11.5 4.75 8 1 4.5 2.5 3l5 5z"/></svg>

After

Width:  |  Height:  |  Size: 161 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="16" viewBox="0 0 10 16"><path fill-rule="evenodd" d="M10 10l-1.5 1.5L5 7.75 1.5 11.5 0 10l5-5 5 5z"/></svg>

After

Width:  |  Height:  |  Size: 166 B

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="far" data-icon="circle" class="svg-inline--fa fa-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"></path></svg>

After

Width:  |  Height:  |  Size: 366 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm0 1.3c1.3 0 2.5.44 3.47 1.17l-8 8A5.755 5.755 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zm0 11.41c-1.3 0-2.5-.44-3.47-1.17l8-8c.73.97 1.17 2.17 1.17 3.47 0 3.14-2.56 5.7-5.7 5.7z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

+1
View File
@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="fas" data-icon="circle" class="svg-inline--fa fa-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z"></path></svg>

After

Width:  |  Height:  |  Size: 283 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M3 5c0-.55.45-1 1-1s1 .45 1 1-.45 1-1 1-1-.45-1-1zm8 0c0-.55-.45-1-1-1s-1 .45-1 1 .45 1 1 1 1-.45 1-1zm0 6c0-.55-.45-1-1-1s-1 .45-1 1 .45 1 1 1 1-.45 1-1zm2-10H5v2.17c.36.19.64.47.83.83h2.34c.42-.78 1.33-1.28 2.34-1.05.75.19 1.36.8 1.53 1.55.31 1.38-.72 2.59-2.05 2.59-.8 0-1.48-.44-1.83-1.09H5.83c-.42.8-1.33 1.28-2.34 1.03-.73-.17-1.34-.78-1.52-1.52C1.72 4.49 2.2 3.59 3 3.17V1H1c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1l5-5h2.17c.42-.78 1.33-1.28 2.34-1.05.75.19 1.36.8 1.53 1.55.31 1.38-.72 2.59-2.05 2.59-.8 0-1.48-.44-1.83-1.09H6.99L4 15h9c.55 0 1-.45 1-1V2c0-.55-.45-1-1-1z"/></svg>

After

Width:  |  Height:  |  Size: 695 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill-rule="evenodd" d="M2 13h4v1H2v-1zm5-6H2v1h5V7zm2 3V8l-3 3 3 3v-2h5v-2H9zM4.5 9H2v1h2.5V9zM2 12h2.5v-1H2v1zm9 1h1v2c-.02.28-.11.52-.3.7-.19.18-.42.28-.7.3H1c-.55 0-1-.45-1-1V4c0-.55.45-1 1-1h3c0-1.11.89-2 2-2 1.11 0 2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V6H1v9h10v-2zM2 5h8c0-.55-.45-1-1-1H8c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H3c-.55 0-1 .45-1 1z"/></svg>

After

Width:  |  Height:  |  Size: 455 B

Some files were not shown because too many files have changed in this diff Show More