initial commit for public repo

Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
This commit is contained in:
Uncle Fatso 2024-12-23 15:01:54 +03:00
commit e63dad2106
Signed by: f4ts0
GPG Key ID: 565F4F2860226EBB
308 changed files with 23277 additions and 0 deletions

1
.env.template Normal file
View File

@ -0,0 +1 @@
REACT_APP_TRACKING_ID=

40
.eslintrc.json Normal file
View File

@ -0,0 +1,40 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/jsx-runtime",
"plugin:react/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "@typescript-eslint"],
"rules": {
"no-console": 0,
"no-empty": 0,
"no-bitwise": 0,
"quotes": [1, "single"],
"jsx-quotes": [1, "prefer-double"],
"semi": [1, "always"],
"@typescript-eslint/prefer-as-const": 1,
"@typescript-eslint/no-namespace": 1,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
}
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.env
.nyc
.nyc_output
node_modules/
build/

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
14

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"semi": true
}

5
assets/favicon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="278" height="278" viewBox="0 0 250 278" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M122.222 127.504L23.3996 71.0796C18.5633 68.3182 18.5334 61.3555 23.346 58.5528L122.199 0.982327C124.442 -0.3236 127.212 -0.32769 129.458 0.971606L228.12 58.0407C232.937 60.8269 232.933 67.7821 228.112 70.5624L129.42 127.488C127.194 128.772 124.454 128.778 122.222 127.504Z" fill="#E6007A"/>
<path d="M246.368 219.626L148.092 276.997C143.282 279.805 137.237 276.349 137.216 270.78L136.785 156.385C136.776 153.791 138.157 151.389 140.406 150.094L239.16 93.1846C243.981 90.4063 250.002 93.8876 250 99.4522L249.953 213.385C249.952 215.955 248.587 218.331 246.368 219.626Z" fill="#E6007A"/>
<path d="M113.198 156.338L113.745 270.133C113.771 275.702 107.756 279.21 102.923 276.443L3.63874 219.619C1.38658 218.33 -0.00209701 215.933 2.37701e-06 213.338L0.0923778 99.3597C0.0968919 93.7951 6.12245 90.3212 10.9404 93.1055L109.586 150.113C111.81 151.399 113.186 153.769 113.198 156.338Z" fill="#E6007A"/>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

46
assets/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>Ghost Telemetry</title>
<meta name="description" content="The GHOST telemetry dashboard provides a real-time view of how currently online nodes are performing." />
<meta name="keywords" content="ghostchain, crypto, blockchain, anonymity, privacy, freedom" />
<style>
html {
background: linear-gradient(90deg, rgba(28,54,100,1) 0%, rgba(45,87,132,1) 100%);
color: #f2e370;
background-repeat: no-repeat;
height: 100%;
}
</style>
<meta property="og:image" content="https://blog.ghostchain.io/wp-content/uploads/2024/12/GHOST-Telemetry-Featured_Images.png" />
<meta property="og:title" content="Ghost Telemetry" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:type" content="website" />
<meta property="og:description" content="The GHOST telemetry dashboard provides a real-time view of how currently online nodes are performing." />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@realGhostChain" />
<meta name="twitter:title" content="Ghost Telemetry" />
<meta name="twitter:description" content="The GHOST telemetry dashboard provides a real-time view of how currently online nodes are performing." />
<meta name="twitter:image" content="https://blog.ghostchain.io/wp-content/uploads/2024/12/GHOST-Telemetry-Featured_Images.png" />
<link rel="icon" href="https://ghostchain.io/wp-content/uploads/2024/10/cropped-logo-32x32.png" sizes="32x32" />
<link rel="icon" href="https://ghostchain.io/wp-content/uploads/2024/10/cropped-logo-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="https://ghostchain.io/wp-content/uploads/2024/10/cropped-logo-180x180.png" />
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>

BIN
assets/meta-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

3
assets/mock.image.js Normal file
View File

@ -0,0 +1,3 @@
// loading images gives back a path. This is what we replace
// that with for tests:
module.exports = 'test-image-stub';

2
assets/mock.style.js Normal file
View File

@ -0,0 +1,2 @@
// For loading styles, give back an empty object in tests:
module.exports = {};

3
images.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';

11
jest.config.js Normal file
View File

@ -0,0 +1,11 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/setupJest.js'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/assets/mock.image.js',
'\\.(css|less|scss|sass)$': '<rootDir>/assets/mock.style.js',
},
};

16809
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "ghost-telemetry-frontend",
"version": "0.1.5",
"author": "Uncle f4tso <f4ts0@ghostchain.io>",
"license": "GPL-3.0",
"description": "Ghost Telemetry frontend",
"scripts": {
"start": "webpack serve --mode=development",
"build": "webpack --mode=production",
"test": "jest",
"pretty:check": "prettier --check src/**/*.{ts,tsx}",
"pretty:fix": "prettier --write src",
"clean": "rm -rf node_modules build ./tmp/env-config.js report*.json npm-error.log",
"lint": "eslint src"
},
"dependencies": {
"@polkadot/util-crypto": "^10.1.7",
"dotenv-webpack": "^8.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^2.1.0"
},
"devDependencies": {
"@types/jest": "29.4.0",
"@types/node": "^16.11.58",
"@types/react": "^18.0.18",
"@types/react-dom": "^18.0.6",
"@types/react-measure": "^2.0.6",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"css-loader": "^6.7.3",
"eslint": "^8.23.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^4.6.0",
"html-webpack-plugin": "^5.5.0",
"jest": "^29.4.0",
"jest-environment-jsdom": "^29.4.0",
"prettier": "^2.0.5",
"style-loader": "^3.3.1",
"ts-jest": "29.0.5",
"ts-loader": "^9.4.2",
"typescript": "^4.9.4",
"webpack": "^5.76.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1",
"whatwg-fetch": "^3.6.2"
},
"lint-staged": {
"src/**/*.{ts,tsx,json,css}": [
"pretty:fix"
]
},
"browserslist": {
"production": [
"chrome >= 67",
"edge >= 79",
"firefox >= 68",
"opera >= 54",
"safari >= 14"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

9
setupJest.js Normal file
View File

@ -0,0 +1,9 @@
// This file runs some code before a jest test.
// polyfill TextEncoder/TextDecoder since they aren't in jsdom:
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// polyfill fetch since it's not in jsdom:
require('whatwg-fetch');

52
src/App.css Normal file
View File

@ -0,0 +1,52 @@
.App {
text-align: left;
font-family: 'Ubuntu', sans-serif;
font-size: 16px;
min-width: 1350px;
}
.App-no-telemetry {
height: 100vh;
min-width: 0px;
font-size: 56px;
text-align: center;
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.App-no-telemetry-spinner {
position: relative;
width: 100%;
height: 300px;
min-height: 240px;
}
.App-no-telemetry-spinner > img {
width: 300px;
height: 300px;
min-height: 240px;
min-width: 240px;
left: 50%;
position: absolute;
}
.App-no-telemetry-spinner > img:nth-of-type(1) {
animation: spin-clockwise 7s linear infinite;
}
.App-no-telemetry-spinner > img:nth-of-type(2) {
animation: spin-anticlockwise 7s linear infinite;
}
@keyframes spin-clockwise {
0% { transform: translateX(-50%) rotate(0deg); }
100% { transform: translateX(-50%) rotate(360deg); }
}
@keyframes spin-anticlockwise {
0% { transform: translateX(-50%) rotate(360deg); }
100% { transform: translateX(-50%) rotate(0deg); }
}

11
src/App.test.tsx Normal file
View File

@ -0,0 +1,11 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
describe('App.tsx', () => {
it('renders without crashing', () => {
const div = document.createElement('div');
const root = createRoot(div);
root.render(<App />);
});
});

236
src/App.tsx Normal file
View File

@ -0,0 +1,236 @@
import * as React from 'react';
import { Types, SortedCollection, Maybe, Compare } from './common';
import { Chain, Ago } from './components';
import { Row, Column } from './components/List';
import { Connection } from './Connection';
import { Persistent, PersistentObject, PersistentSet } from './persist';
import {
bindState,
State,
Update,
Node,
ChainData,
comparePinnedChains,
StateSettings,
} from './state';
import { getHashData } from './utils';
import innerCircleIcon from './assets/inner-circle.svg';
import outerCircleIcon from './assets/outer-circle.svg';
import './App.css';
export default class App extends React.Component {
private chainsCache: ChainData[] = [];
// Custom state for finer control over updates
private readonly appState: Readonly<State>;
private readonly appUpdate: Update;
private readonly settings: PersistentObject<StateSettings>;
private readonly pins: PersistentSet<Types.NodeName>;
private readonly sortBy: Persistent<Maybe<number>>;
private readonly connection: Promise<Connection>;
constructor(props: Record<string, unknown>) {
super(props);
//todo! Check what's important to set to true, false
this.settings = new PersistentObject(
'settings',
{
validator: true,
location: true,
implementation: true,
networkId: true,
peers: true,
txs: true,
upload: false,
download: false,
stateCacheSize: false,
dbCacheSize: false,
diskRead: false,
diskWrite: false,
blocknumber: true,
blockhash: true,
finalized: false,
finalizedhash: false,
blocktime: true,
blockpropagation: true,
blocklasttime: false,
uptime: false,
version: false,
target_os: false,
target_arch: false,
cpu: false,
cpu_hashrate_score: true,
cpu_vendor: true,
core_count: false,
mem: true,
memory: false,
linux_distro: false,
linux_kernel: false,
memory_memcpy_score: true,
disk_sequential_write_score: true,
disk_random_write_score: true,
is_virtual_machine: false,
},
(settings) => {
const selectedColumns = this.selectedColumns(settings);
this.sortBy.set(null);
this.appUpdate({ settings, selectedColumns, sortBy: null });
}
);
this.pins = new PersistentSet<Types.NodeName>('pinned_names', (pins) => {
const { nodes } = this.appState;
nodes.mutEachAndSort((node) => node.setPinned(pins.has(node.name)));
this.appUpdate({ nodes, pins });
});
this.sortBy = new Persistent<Maybe<number>>('sortBy', null, (sortBy) => {
const compare = this.getComparator(sortBy);
this.appState.nodes.setComparator(compare);
this.appUpdate({ sortBy });
});
const { tab = '' } = getHashData();
this.appUpdate = bindState(this, {
status: 'offline',
best: 0 as Types.BlockNumber,
finalized: 0 as Types.BlockNumber,
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,
chainStats: null,
});
this.appState = this.appUpdate({});
const comparator = this.getComparator(this.sortBy.get());
this.appState.nodes.setComparator(comparator);
this.connection = Connection.create(
this.pins,
this.appState,
this.appUpdate
);
setInterval(() => (this.chainsCache = []), 10000);
}
public render() {
const { timeDiff, subscribed, status, tab } = this.appState;
const chains = this.chains();
const subscribedData = subscribed
? this.appState.chains.get(subscribed)
: null;
Ago.timeDiff = timeDiff;
if (chains.length === 0) {
return (
<div className="App App-no-telemetry">
<div className="App-no-telemetry-spinner">
<img src={innerCircleIcon} />
<img src={outerCircleIcon} />
</div>
<div className="App-no-telemetry-text">
Waiting for Ghosties&hellip;
</div>
</div>
);
}
return (
<div className="App">
<Chain
appState={this.appState}
appUpdate={this.appUpdate}
connection={this.connection}
settings={this.settings}
pins={this.pins}
sortBy={this.sortBy}
/>
</div>
);
}
public componentDidMount() {
window.addEventListener('hashchange', this.onHashChange);
}
public componentWillUnmount() {
window.removeEventListener('hashchange', this.onHashChange);
}
private onHashChange = () => {
const { tab = '' } = getHashData();
this.appUpdate({ tab });
};
private chains(): ChainData[] {
if (this.chainsCache.length === this.appState.chains.size) {
return this.chainsCache;
}
this.chainsCache = Array.from(this.appState.chains.values()).sort(
(a, b) => {
const pinned = comparePinnedChains(a.genesisHash, b.genesisHash);
if (pinned !== 0) {
return pinned;
}
return b.nodeCount - a.nodeCount;
}
);
return this.chainsCache;
}
private selectedColumns(settings: StateSettings): Column[] {
return Row.columns.filter(
({ setting }) => setting == null || settings[setting]
);
}
private getComparator(sortBy: Maybe<number>): Compare<Node> {
const columns = this.appState.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;
}
}

540
src/Connection.ts Normal file
View File

@ -0,0 +1,540 @@
import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from './common';
import { State, Update, Node, ChainData, PINNED_CHAINS } from './state';
import { PersistentSet } from './persist';
import { getHashData, setHashData } from './utils';
import { ACTIONS } from './common/feed';
import {
Column,
LocationColumn,
PeersColumn,
TxsColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
} from './components/List';
const CONNECTION_TIMEOUT_BASE = (1000 * 5) as Types.Milliseconds; // 5 seconds
const CONNECTION_TIMEOUT_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes
const MESSAGE_TIMEOUT = (1000 * 60) as Types.Milliseconds; // 60 seconds
declare global {
interface Window {
process_env: string;
}
}
export class Connection {
public static async create(
pins: PersistentSet<Types.NodeName>,
appState: Readonly<State>,
appUpdate: Update
): Promise<Connection> {
return new Connection(await Connection.socket(), appState, appUpdate, pins);
}
private static readonly utf8decoder = new TextDecoder('utf-8');
private static readonly address = Connection.getAddress();
private static getAddress(): string {
const ENV_URL = 'SUBSTRATE_TELEMETRY_URL';
// If env_config.js is generated and loaded in, it'll set this variable.
// This is set up in the Dockerfile. Otherwise, we just connect to a
// default URL.
if (window.process_env?.[ENV_URL]) {
return window.process_env[ENV_URL] as string;
}
if (window.location.protocol === 'https:') {
return `wss://${window.location.hostname}/feed/`;
}
return 'ws://127.0.0.1:8000/feed';
}
private static async socket(): Promise<WebSocket> {
let socket = await Connection.trySocket();
let timeout = CONNECTION_TIMEOUT_BASE;
while (!socket) {
await sleep(timeout);
timeout = Math.min(
timeout * 2,
CONNECTION_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);
});
}
// timer which will force a reconnection if no message is seen for a while
private messageTimeout: Maybe<ResettableTimeout> = null;
// id sent to the backend used to pair responses
private pingId = 0;
// timeout handler for ping messages
private pingTimeout: NodeJS.Timer;
// timestamp at which the last ping has been sent
private pingSent: Maybe<Types.Timestamp> = null;
// chain label to resubsribe to on reconnect
private resubscribeTo: Maybe<Types.GenesisHash> = getHashData().chain;
constructor(
private socket: WebSocket,
private readonly appState: Readonly<State>,
private readonly appUpdate: Update,
private readonly pins: PersistentSet<Types.NodeName>
) {
this.bindSocket();
}
public subscribe(chain: Types.GenesisHash) {
if (
this.appState.subscribed != null &&
this.appState.subscribed !== chain
) {
this.appUpdate({
tab: 'list',
});
setHashData({ chain, tab: 'list' });
} else {
setHashData({ chain });
}
this.socket.send(`subscribe:${chain}`);
}
private handleMessages = (messages: FeedMessage.Message[]) => {
this.messageTimeout?.reset();
const { nodes, chains, sortBy, selectedColumns } = this.appState;
const nodesStateRef = nodes.ref;
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) {
return this.newVersion();
}
break;
}
case ACTIONS.BestBlock: {
const [best, blockTimestamp, blockAverage] = message.payload;
nodes.mutEach((node) => node.newBestBlock());
this.appUpdate({ best, blockTimestamp, blockAverage });
break;
}
case ACTIONS.BestFinalized: {
const [finalized /*, hash */] = message.payload;
this.appUpdate({ finalized });
break;
}
case ACTIONS.AddedNode: {
const [
id,
nodeDetails,
nodeStats,
nodeIO,
nodeHardware,
blockDetails,
location,
startupTime,
] = message.payload;
const pinned = this.pins.has(nodeDetails[0]);
const node = new Node(
pinned,
id,
nodeDetails,
nodeStats,
nodeIO,
nodeHardware,
blockDetails,
location,
startupTime
);
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 === LocationColumn
);
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 === FinalizedBlockColumn ||
sortByColumn === FinalizedHashColumn
);
break;
}
case ACTIONS.NodeStats: {
const [id, nodeStats] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateStats(nodeStats),
sortByColumn === PeersColumn || sortByColumn === TxsColumn
);
break;
}
case ACTIONS.NodeHardware: {
const [id, nodeHardware] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateHardware(nodeHardware),
sortByColumn === UploadColumn || sortByColumn === DownloadColumn
);
break;
}
case ACTIONS.NodeIO: {
const [id, nodeIO] = message.payload;
nodes.mutAndMaybeSort(
id,
(node) => node.updateIO(nodeIO),
sortByColumn === StateCacheColumn
);
break;
}
case ACTIONS.TimeSync: {
this.appUpdate({
timeDiff: (timestamp() - message.payload) as Types.Milliseconds,
});
break;
}
case ACTIONS.AddedChain: {
const [label, genesisHash, nodeCount] = message.payload;
const chain = chains.get(genesisHash);
if (chain) {
chain.nodeCount = nodeCount;
} else {
chains.set(genesisHash, { label, genesisHash, nodeCount });
}
this.appUpdate({ chains });
break;
}
case ACTIONS.RemovedChain: {
chains.delete(message.payload);
if (this.appState.subscribed === message.payload) {
nodes.clear();
this.appUpdate({ subscribed: null, nodes, chains });
}
break;
}
case ACTIONS.SubscribedTo: {
nodes.clear();
this.appUpdate({ subscribed: message.payload, nodes });
break;
}
case ACTIONS.UnsubscribedFrom: {
if (this.appState.subscribed === message.payload) {
nodes.clear();
this.appUpdate({ subscribed: null, nodes });
}
break;
}
case ACTIONS.Pong: {
this.pong(Number(message.payload));
break;
}
case ACTIONS.ChainStatsUpdate: {
this.appUpdate({ chainStats: message.payload });
break;
}
default: {
break;
}
}
}
if (nodes.hasChangedSince(nodesStateRef)) {
this.appUpdate({ nodes });
}
this.autoSubscribe();
};
private bindSocket() {
console.log('Connected');
// Disconnect if no messages are received in 60s:
this.messageTimeout = resettableTimeout(
this.handleDisconnect,
MESSAGE_TIMEOUT
);
// Ping periodically to keep the above happy even if no other data is coming in:
this.ping();
if (this.appState) {
const { nodes } = this.appState;
nodes.clear();
}
this.appUpdate({
status: 'online',
});
if (this.appState.subscribed) {
this.resubscribeTo = this.appState.subscribed;
this.appUpdate({ subscribed: null });
}
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}`);
};
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;
console.log(`Ping latency: ${latency}ms`);
this.pingSent = null;
// Schedule a new ping to be sent at least 30s after the last one:
this.pingTimeout = setTimeout(
this.ping,
Math.max(0, MESSAGE_TIMEOUT / 2 - latency)
);
}
private newVersion() {
this.appUpdate({ status: 'upgrade-requested' });
this.clean();
// Force reload from the server
setTimeout(() => window.location.reload(), 3000);
}
private clean() {
clearTimeout(this.pingTimeout);
this.pingSent = null;
this.messageTimeout?.cancel();
this.messageTimeout = null;
this.socket.removeEventListener('message', this.handleFeedData);
this.socket.removeEventListener('close', this.handleDisconnect);
this.socket.removeEventListener('error', this.handleDisconnect);
}
private handleFeedData = (event: MessageEvent) => {
let data: FeedMessage.Data;
if (typeof event.data === 'string') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data = event.data as any as FeedMessage.Data;
} else {
const u8aData = new Uint8Array(event.data);
// Future-proofing for when we switch to binary feed
if (u8aData[0] === 0x00) {
return this.newVersion();
}
const str = Connection.utf8decoder.decode(event.data);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data = str as any as FeedMessage.Data;
}
this.handleMessages(FeedMessage.deserialize(data));
};
private autoSubscribe() {
const { subscribed, chains } = this.appState;
const { resubscribeTo } = this;
if (subscribed) {
return;
}
if (resubscribeTo) {
if (chains.has(resubscribeTo)) {
this.subscribe(resubscribeTo);
return;
}
}
let topChain: Maybe<ChainData> = null;
for (const chain of chains.values()) {
if (PINNED_CHAINS[chain.genesisHash] === 1) {
topChain = chain;
break;
}
if (!topChain || chain.nodeCount > topChain.nodeCount) {
topChain = chain;
}
}
if (topChain) {
this.subscribe(topChain.genesisHash);
}
}
private handleDisconnect = async () => {
console.warn('Disconnecting; will attempt reconnect');
this.appUpdate({ status: 'offline' });
this.clean();
this.socket.close();
this.socket = await Connection.socket();
this.bindSocket();
};
}
/**
* Fire a function if the timer runs out. You can reset it, or
* cancel it to prevent the function from being fired.
*
* @param onExpired
* @param timeoutMs
* @returns
*/
function resettableTimeout(
onExpired: () => void,
timeoutMs: Types.Milliseconds
) {
let timer = setTimeout(onExpired, timeoutMs);
return {
reset() {
clearTimeout(timer);
timer = setTimeout(onExpired, timeoutMs);
},
cancel() {
clearTimeout(timer);
},
};
}
type ResettableTimeout = ReturnType<typeof resettableTimeout>;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Layer_2_00000103961323752282420440000002190430745304150146_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 223 223"
style="enable-background:new 0 0 223 223;" xml:space="preserve">
<g id="Layer_1-2">
<circle style="fill:none;" cx="111.5" cy="111.5" r="111.5"/>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="115.7906" y1="720.4291" x2="34.4315" y2="682.4843" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M101.7,177.2c-0.1,0-0.3,0-0.4,0l-0.4-0.1l-5.3-1.1l-1.7-0.4c-0.4-0.1-0.8-0.2-1.2-0.3
c-0.8-0.2-1.5-0.4-2.3-0.6l-0.1,0l-3.4-1.1c-4.6-1.6-9-3.7-13.2-6.1c-8.5-5.1-15.7-12.2-21-20.6c-7.8-12.7-11.4-27.6-10.3-42.5
c0.3-4.3,1.2-8.7,2.5-12.8c1-3,4.3-4.6,7.3-3.6s4.6,4.3,3.6,7.3c0,0,0,0,0,0c0,0.1,0,0.1-0.1,0.2c-1.2,3.2-1.9,6.5-2.2,9.9
c-0.4,4.2-0.4,8.4,0,12.5c0.8,8.4,3.5,16.5,7.7,23.8c4.3,7.3,10.2,13.4,17.3,18c3.6,2.3,7.4,4.2,11.4,5.7l3.1,1.1
c0.6,0.2,1.3,0.4,1.9,0.6c0.4,0.1,0.8,0.2,1.2,0.4l1.5,0.4l5,1.2c2.2,0.7,3.4,3,2.8,5.2C105,176,103.5,177.2,101.7,177.2
L101.7,177.2L101.7,177.2z"/>
<linearGradient id="SVGID_00000024700464465339729840000004120134249967546251_" gradientUnits="userSpaceOnUse" x1="82.875" y1="667.6512" x2="139.7121" y2="610.8142" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_00000024700464465339729840000004120134249967546251_);" d="M159.2,76.9c-1.9,0-3.7-0.9-4.7-2.5
c-2-3-4.4-5.7-7.2-8c-3.4-2.8-7-5.3-11-7.3c-7.9-4.1-16.6-6.3-25.5-6.7c-4.4-0.2-8.8,0.2-13.2,1.2c-4.3,0.9-8.5,2.4-12.5,4.4
c-4,2-7.8,4.4-11.3,7.3c-3.5,2.9-6.7,6.1-9.7,9.6l-0.3,0.3c-1.8,1.5-4.4,1.3-5.9-0.4c-1.2-1.3-1.3-3.2-0.5-4.8l0.3-0.4
c3.2-3.9,6.7-7.6,10.4-11c3.9-3.3,8.2-6.3,12.7-8.7c9.2-4.9,19.5-7.4,30-7.3c10.4,0.1,20.7,2.5,30.1,7c4.7,2.2,9.2,5,13.3,8.3
c3.6,2.9,6.9,6.3,9.6,10c1.8,2.6,1.2,6.2-1.4,8c-0.1,0.1-0.3,0.2-0.4,0.2C161.2,76.7,160.2,77,159.2,76.9L159.2,76.9z"/>
<linearGradient id="SVGID_00000038414855759987205370000008965600844213966738_" gradientUnits="userSpaceOnUse" x1="149.5093" y1="656.3518" x2="149.5093" y2="745.62" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_00000038414855759987205370000008965600844213966738_);" d="M126.8,177.6c-3.2,0-5.7-2.5-5.7-5.7
c0-2.6,1.7-4.9,4.2-5.5c7-1.9,13.7-5.1,19.6-9.4c6.5-4.6,12-10.5,16.2-17.2c4.2-6.8,6.8-14.4,7.7-22.3c0.8-8.1,0-16.2-2.2-24
l-0.1-0.3c-0.4-2.3,1.1-4.4,3.4-4.8c1.8-0.3,3.5,0.6,4.4,2.2l0.1,0.3l0.1,0.3c2.8,8.8,4,18,3.3,27.2c-0.7,9.3-3.6,18.4-8.2,26.5
c-9.2,15.6-23.9,27.2-41.2,32.5C127.9,177.5,127.3,177.6,126.8,177.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 223 223">
<defs>
<style>.a{fill:none;}.b{fill:url(#a);}.c{fill:url(#b);}.d{fill:url(#c);}</style>
<linearGradient id="a" x1="115.8069" y1="152.4435" x2="34.4357" y2="114.4995" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
<stop offset="0.1375" stop-color="#a1a1a1" stop-opacity="0.3685"/>
<stop offset="1"/>
</linearGradient>
<linearGradient id="b" x1="82.8779" y1="99.6568" x2="139.7257" y2="42.809" xlink:href="#a"/>
<linearGradient id="c" x1="149.512" y1="88.35" x2="149.512" y2="177.6234" xlink:href="#a"/>
</defs>
<circle class="a" cx="111.5" cy="111.5" r="111.5"/>
<path d="M101.7051,177.1943a4.0012,4.0012,0,0,1-.4022-.02l-.3562-.0528-5.2762-1.0645-1.6672-.4362c-.4082-.11-.788-.2081-1.1678-.3066-.7664-.1976-1.5324-.3961-2.29-.6242l-.0928-.0288-3.3721-1.111a73.5989,73.5989,0,0,1-13.1918-6.1478,63.57,63.57,0,0,1-20.9464-20.5767A71.5082,71.5082,0,0,1,42.63,104.3473,51.4261,51.4261,0,0,1,45.165,91.5641a5.7194,5.7194,0,1,1,10.811,3.7373q-.027.0783-.0564.1559a40.5484,40.5484,0,0,0-2.238,9.9322,58.8594,58.8594,0,0,0,.0417,12.5134,59.6656,59.6656,0,0,0,7.6914,23.79,54.0709,54.0709,0,0,0,17.2938,17.98,64.6922,64.6922,0,0,0,11.3772,5.7013l3.0992,1.12c.63.2081,1.2674.3922,1.9049.5763.4038.1169.808.2337,1.21.3554l1.5.441,5.0341,1.1662a4.1733,4.1733,0,0,1-1.1293,8.1608Z"/>
<path d="M159.2084,76.9486a5.6916,5.6916,0,0,1-4.7435-2.5313,38.3223,38.3223,0,0,0-7.2208-7.9928,57.2168,57.2168,0,0,0-10.955-7.28,60.6741,60.6741,0,0,0-25.5179-6.6753A55.1161,55.1161,0,0,0,97.5986,53.617a53.45,53.45,0,0,0-12.49,4.3709,62.2047,62.2047,0,0,0-11.2972,7.2588,80.4544,80.4544,0,0,0-9.6638,9.6006l-.29.3A4.1827,4.1827,0,0,1,57.4664,69.95l.3014-.429a88.9654,88.9654,0,0,1,10.454-10.9527,71.2747,71.2747,0,0,1,12.7435-8.6923A62.6057,62.6057,0,0,1,110.96,42.5647a71.0985,71.0985,0,0,1,30.0809,6.9891,68.1868,68.1868,0,0,1,13.271,8.31,49.6737,49.6737,0,0,1,9.5544,10.0315,5.7332,5.7332,0,0,1-1.35,7.9946q-.1887.1341-.3876.2527A5.6969,5.6969,0,0,1,159.2084,76.9486Z"/>
<path d="M126.76,177.6233a5.7144,5.7144,0,0,1-1.52-11.2279,61.3735,61.3735,0,0,0,19.6125-9.3711,60.6979,60.6979,0,0,0,16.2365-17.24,53.5379,53.5379,0,0,0,7.665-22.2911,63.3719,63.3719,0,0,0-2.2267-23.99l-.0677-.2922a4.17,4.17,0,0,1,7.8023-2.6333l.1493.2906.0956.3122a72.0341,72.0341,0,0,1,3.3141,27.1823,63.0029,63.0029,0,0,1-8.2232,26.4915,72.467,72.467,0,0,1-41.174,32.523A5.7222,5.7222,0,0,1,126.76,177.6233Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.0" id="Layer_2_00000035512755216235750980000003796724444709727912_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 223 223"
style="enable-background:new 0 0 223 223;" xml:space="preserve">
<g id="Layer_1-2">
<circle style="fill:none;" cx="111.5" cy="111.5" r="111.5"/>
<linearGradient id="SVGID_00000120554527348650398850000014100179473629740711_" gradientUnits="userSpaceOnUse" x1="95.9513" y1="704.4023" x2="167.8534" y2="776.3043" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_00000120554527348650398850000014100179473629740711_);" d="M192.2,141c-9.9-0.1-18,7.8-18.1,17.7c0,3.4,0.9,6.8,2.7,9.6c-4.5,5.2-9.5,10-15,14.1
c-10,7.6-21.5,13-33.8,15.7c-12.4,2.7-25.2,2.5-37.5-0.4c-11.4-2.6-23.2-8-34.9-15.9l-0.1-0.1c-2.2-1.3-5.1-0.6-6.4,1.6
c-1.2,2-0.8,4.6,1.1,6.1l0.1,0.1c12.5,9,25.1,15.3,37.6,18.7c8.1,2.3,16.5,3.4,24.9,3.5c5.9,0.1,11.8-0.4,17.7-1.5
c14.2-2.6,27.7-8.3,39.5-16.7c7-4.9,13.4-10.6,19.1-17.1c9.8,1.6,19-4.9,20.6-14.7c1.6-9.8-4.9-19-14.7-20.6
C194,141,193.1,141,192.2,141L192.2,141L192.2,141z"/>
<linearGradient id="SVGID_00000120554527348650398850000014100179473629740724_" gradientUnits="userSpaceOnUse" x1="89.193" y1="666.3314" x2="9.5603" y2="666.3314" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_00000120554527348650398850000014100179473629740724_);" d="M51.8,158.5c-3.1-7.8-11.3-12.4-19.6-11
c-10.1-20.7-12.1-44.4-5.7-66.4c2.1-6.8,5-13.3,8.8-19.4c3.8-6.1,8.4-11.6,13.7-16.5c5.3-4.9,11.1-9.3,17.3-13
c6.4-3.7,13.1-6.9,20-9.5l0.6-0.2l0.5-0.4c1.6-1.3,2.2-3.4,1.5-5.3c-0.8-2.2-3.1-3.5-5.4-2.9l-0.4,0.1c-7.5,2.6-14.8,5.8-21.8,9.5
c-7,3.9-13.6,8.5-19.6,13.8c-6.1,5.4-11.5,11.5-16,18.2c-4.6,6.8-8.2,14.1-10.8,21.9c-5.2,15.5-6.6,32.1-4.1,48.3
c1.5,10.5,4.6,20.6,9.2,30.2c-5.2,8.4-2.6,19.4,5.8,24.6s19.4,2.6,24.6-5.8C53.3,169.7,53.9,163.7,51.8,158.5L51.8,158.5
L51.8,158.5z"/>
<linearGradient id="SVGID_00000124152774908586921190000015569799832479483270_" gradientUnits="userSpaceOnUse" x1="152.6035" y1="707.0817" x2="152.6035" y2="568.2065" gradientTransform="matrix(1 0 0 1 0 -568)">
<stop offset="0" style="stop-color:#FFFFFF;stop-opacity:0"/>
<stop offset="0.14" style="stop-color:#FFFFFF;stop-opacity:0.37"/>
<stop offset="1" style="stop-color:#FFFFFF"/>
</linearGradient>
<path style="fill:url(#SVGID_00000124152774908586921190000015569799832479483270_);" d="M107.5,35.4c8,2.2,16.5-1.4,20.4-8.7
C148.5,31,167.1,42,180.6,58c8.6,10.3,14.6,22.4,17.7,35.4c2.9,12.1,3.2,25.7,0.9,40.3l-0.1,0.5c-0.1,2.3,1.6,4.3,3.9,4.7
c0.2,0,0.4,0.1,0.7,0.1c1.8,0.1,3.5-1,4.3-2.6l0.2-0.5l0.1-0.5c3.1-15.9,3.3-30.8,0.5-44.3c-3-14.9-9.4-28.9-18.7-40.8
c-9.4-11.9-21.3-21.7-34.8-28.7c-8.3-4.3-17.2-7.5-26.4-9.6c-0.3-0.9-0.8-1.9-1.3-2.8c-4.9-8.6-15.8-11.6-24.4-6.7
S91.8,18.4,96.7,27C99,31.1,102.9,34.1,107.5,35.4L107.5,35.4L107.5,35.4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

1
src/assets/world-map.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 323 KiB

View File

@ -0,0 +1,240 @@
import { Maybe, Opaque } from './helpers';
export type Compare<T> = (a: T, b: T) => number;
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;
}
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;
}
type StateRef = Opaque<number, 'SortedCollection.StateRef'>;
interface Focus {
start: number;
end: number;
}
export class SortedCollection<Item extends { id: number }> {
// Comparator function used to sort the collection
private compare: Compare<Item>;
// Mapping item `id` to the `Item`, this uses array as a structure with
// the assumption that `id`s provided are increments from `0`, and that
// vacant `id`s will be re-used in the future.
private map = Array<Maybe<Item>>();
// Actual sorted list of `Item`s.
private list = Array<Item>();
// Internal tracker for changes, this number increments whenever the
// order of the **focused** elements in the collection changes
private changeRef = 0;
// Marks the range of indicies that are focused for tracking.
// **Note:** `start` is inclusive, while `end` is exclusive (much like
// `Array.slice()`).
private focus: Focus = { start: 0, end: 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 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;
const index = sortedInsert(item, this.list, this.compare);
if (index < this.focus.end) {
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;
if (index < this.focus.end) {
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;
}
const index = sortedIndexOf(item, this.list, this.compare);
mutator(item);
if (index >= this.focus.start && index < this.focus.end) {
this.changeRef += 1;
}
}
public mutAndSort(id: number, mutator: (item: Item) => void) {
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) {
const outOfFocus =
(index < this.focus.start && newIndex < this.focus.start) ||
(index >= this.focus.end && newIndex >= this.focus.end);
if (!outOfFocus) {
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);
this.changeRef += 1;
}
public clear() {
this.map = [];
this.list = [];
this.changeRef += 1;
}
// Set a new `Focus`. Any changes to the order of items within the `Focus`
// will increment `changeRef`.
public setFocus(start: number, end: number) {
this.focus = { start, end };
}
// Get the reference to current ordering state of focused items.
public get ref(): StateRef {
return this.changeRef as StateRef;
}
// Check if order of focused items has changed since obtaining a `ref`.
public hasChangedSince(ref: StateRef): boolean {
return this.changeRef > ref;
}
}

83
src/common/common.test.ts Normal file
View File

@ -0,0 +1,83 @@
import { sortedInsert, sortedIndexOf } from '.';
describe('sortedInsert', () => {
it('inserts a value in the correct place', () => {
function assertInsert(item: number, into: number[], equals: number[]) {
const cmp = (a: number, b: number) => a - b;
sortedInsert(item, into, cmp);
expect(into).toStrictEqual(equals);
}
assertInsert(1, [2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(2, [1, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(3, [1, 2, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(4, [1, 2, 3, 5, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(5, [1, 2, 3, 4, 6, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(6, [1, 2, 3, 4, 5, 7, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(7, [1, 2, 3, 4, 5, 6, 8, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(8, [1, 2, 3, 4, 5, 6, 7, 9], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
assertInsert(9, [1, 2, 3, 4, 5, 6, 7, 8], [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it('fuzz tests insert as expected', () => {
const cmp = (a: number, b: number) => a - b;
const scramble = () => Math.random() - 0.5;
const sorted = [1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let i = 0; i < 50; i++) {
const scrambled = sorted.sort(scramble);
const resorted: number[] = [];
for (const item of scrambled) {
sortedInsert(item, resorted, cmp);
}
expect(resorted).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
});
it('indexes', () => {
const cmp = (a: number, b: number) => a - b;
const into: number[] = [];
expect(sortedInsert(5, into, cmp)).toStrictEqual(0);
expect(into).toStrictEqual([5]);
expect(sortedInsert(1, into, cmp)).toStrictEqual(0);
expect(into).toStrictEqual([1, 5]);
expect(sortedInsert(9, into, cmp)).toStrictEqual(2);
expect(into).toStrictEqual([1, 5, 9]);
expect(sortedInsert(3, into, cmp)).toStrictEqual(1);
expect(into).toStrictEqual([1, 3, 5, 9]);
expect(sortedInsert(7, into, cmp)).toStrictEqual(3);
expect(into).toStrictEqual([1, 3, 5, 7, 9]);
expect(sortedInsert(4, into, cmp)).toStrictEqual(2);
expect(into).toStrictEqual([1, 3, 4, 5, 7, 9]);
expect(sortedInsert(6, into, cmp)).toStrictEqual(4);
expect(into).toStrictEqual([1, 3, 4, 5, 6, 7, 9]);
expect(sortedInsert(2, into, cmp)).toStrictEqual(1);
expect(into).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 9]);
expect(sortedInsert(8, into, cmp)).toStrictEqual(7);
expect(into).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it('sortedIndexOf', () => {
type ValueObj = {
value: number;
};
const cmp = (a: ValueObj, b: ValueObj) => a.value - b.value;
const array: Array<ValueObj> = [];
for (let i = 1; i <= 1000; i++) {
array.push({ value: i >> 1 });
}
for (let i = 0; i < 50; i++) {
const index = (Math.random() * 1000) | 0;
const item = array[index];
expect(sortedIndexOf(item, array, cmp)).toStrictEqual(
array.indexOf(item)
);
}
});
});

243
src/common/feed.ts Normal file
View File

@ -0,0 +1,243 @@
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,
GenesisHash,
AuthoritySetInfo,
ChainStats,
} from './types';
export const ACTIONS = {
FeedVersion: 0x00 as const,
BestBlock: 0x01 as const,
BestFinalized: 0x02 as const,
AddedNode: 0x03 as const,
RemovedNode: 0x04 as const,
LocatedNode: 0x05 as const,
ImportedBlock: 0x06 as const,
FinalizedBlock: 0x07 as const,
NodeStats: 0x08 as const,
NodeHardware: 0x09 as const,
TimeSync: 0x0a as const,
AddedChain: 0x0b as const,
RemovedChain: 0x0c as const,
SubscribedTo: 0x0d as const,
UnsubscribedFrom: 0x0e as const,
Pong: 0x0f as const,
AfgFinalized: 0x10 as const,
AfgReceivedPrevote: 0x11 as const,
AfgReceivedPrecommit: 0x12 as const,
AfgAuthoritySet: 0x13 as const,
StaleNode: 0x14 as const,
NodeIO: 0x15 as const,
ChainStatsUpdate: 0x16 as const,
};
export type Action = typeof ACTIONS[keyof typeof ACTIONS];
export type Payload = Message['payload'];
interface MessageBase {
action: Action;
}
interface FeedVersionMessage extends MessageBase {
action: typeof ACTIONS.FeedVersion;
payload: FeedVersion;
}
interface BestBlockMessage extends MessageBase {
action: typeof ACTIONS.BestBlock;
payload: [BlockNumber, Timestamp, Maybe<Milliseconds>];
}
interface BestFinalizedBlockMessage extends MessageBase {
action: typeof ACTIONS.BestFinalized;
payload: [BlockNumber, BlockHash];
}
interface AddedNodeMessage extends MessageBase {
action: typeof ACTIONS.AddedNode;
payload: [
NodeId,
NodeDetails,
NodeStats,
NodeIO,
NodeHardware,
BlockDetails,
Maybe<NodeLocation>,
Maybe<Timestamp>
];
}
interface RemovedNodeMessage extends MessageBase {
action: typeof ACTIONS.RemovedNode;
payload: NodeId;
}
interface LocatedNodeMessage extends MessageBase {
action: typeof ACTIONS.LocatedNode;
payload: [NodeId, Latitude, Longitude, City];
}
interface ImportedBlockMessage extends MessageBase {
action: typeof ACTIONS.ImportedBlock;
payload: [NodeId, BlockDetails];
}
interface FinalizedBlockMessage extends MessageBase {
action: typeof ACTIONS.FinalizedBlock;
payload: [NodeId, BlockNumber, BlockHash];
}
interface NodeStatsMessage extends MessageBase {
action: typeof ACTIONS.NodeStats;
payload: [NodeId, NodeStats];
}
interface NodeHardwareMessage extends MessageBase {
action: typeof ACTIONS.NodeHardware;
payload: [NodeId, NodeHardware];
}
interface NodeIOMessage extends MessageBase {
action: typeof ACTIONS.NodeIO;
payload: [NodeId, NodeIO];
}
interface TimeSyncMessage extends MessageBase {
action: typeof ACTIONS.TimeSync;
payload: Timestamp;
}
interface AddedChainMessage extends MessageBase {
action: typeof ACTIONS.AddedChain;
payload: [ChainLabel, GenesisHash, NodeCount];
}
interface RemovedChainMessage extends MessageBase {
action: typeof ACTIONS.RemovedChain;
payload: GenesisHash;
}
interface SubscribedToMessage extends MessageBase {
action: typeof ACTIONS.SubscribedTo;
payload: GenesisHash;
}
interface UnsubscribedFromMessage extends MessageBase {
action: typeof ACTIONS.UnsubscribedFrom;
payload: GenesisHash;
}
interface PongMessage extends MessageBase {
action: typeof ACTIONS.Pong;
payload: string; // just echo whatever `ping` sent
}
interface AfgFinalizedMessage extends MessageBase {
action: typeof ACTIONS.AfgFinalized;
payload: [Address, BlockNumber, BlockHash];
}
interface AfgAuthoritySet extends MessageBase {
action: typeof ACTIONS.AfgAuthoritySet;
payload: AuthoritySetInfo;
}
interface AfgReceivedPrecommit extends MessageBase {
action: typeof ACTIONS.AfgReceivedPrecommit;
payload: [Address, BlockNumber, BlockHash, Address];
}
interface AfgReceivedPrevote extends MessageBase {
action: typeof ACTIONS.AfgReceivedPrevote;
payload: [Address, BlockNumber, BlockHash, Address];
}
interface StaleNodeMessage extends MessageBase {
action: typeof ACTIONS.StaleNode;
payload: NodeId;
}
interface ChainStatsUpdate extends MessageBase {
action: typeof ACTIONS.ChainStatsUpdate;
payload: ChainStats;
}
export type Message =
| FeedVersionMessage
| BestBlockMessage
| BestFinalizedBlockMessage
| AddedNodeMessage
| RemovedNodeMessage
| LocatedNodeMessage
| ImportedBlockMessage
| FinalizedBlockMessage
| NodeStatsMessage
| NodeHardwareMessage
| TimeSyncMessage
| AddedChainMessage
| RemovedChainMessage
| SubscribedToMessage
| UnsubscribedFromMessage
| AfgFinalizedMessage
| AfgReceivedPrevote
| AfgReceivedPrecommit
| AfgAuthoritySet
| StaleNodeMessage
| PongMessage
| NodeIOMessage
| ChainStatsUpdate;
export type SquashedMessages = Array<Action | Payload>;
export type Data = Stringified<SquashedMessages>;
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);
}
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;
}

82
src/common/helpers.ts Normal file
View File

@ -0,0 +1,82 @@
import { Milliseconds, Timestamp } from './types';
export abstract class PhantomData<P> {
public __PHANTOM__: P;
}
export type Opaque<T, P> = T & PhantomData<P>;
export type Maybe<T> = T | null | undefined;
export function sleep(time: Milliseconds): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), time);
});
}
export const timestamp = Date.now as () => Timestamp;
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;
}
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;
}
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;
}
}

9
src/common/id.ts Normal file
View File

@ -0,0 +1,9 @@
import { Opaque } from './helpers';
export type Id<T> = Opaque<number, T>;
export function idGenerator<I extends Id<any>>(): () => I {
let current = 0;
return () => current++ as I;
}

11
src/common/index.ts Normal file
View File

@ -0,0 +1,11 @@
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 };
export const VERSION: Types.FeedVersion = 32 as Types.FeedVersion;

84
src/common/iterators.ts Normal file
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
src/common/stringify.ts Normal file
View File

@ -0,0 +1,8 @@
export abstract class Stringified<T> {
public __PHANTOM__: T;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const parse = JSON.parse as any as <T>(val: Stringified<T>) => T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const stringify = JSON.stringify as any as <T>(val: T) => Stringified<T>;

122
src/common/types.ts Normal file
View File

@ -0,0 +1,122 @@
import { Opaque, Maybe } from './helpers';
import { Id } from './id';
export type FeedVersion = Opaque<number, 'FeedVersion'>;
export type ChainLabel = Opaque<string, 'ChainLabel'>;
export type GenesisHash = Opaque<string, 'GenesisHash'>;
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 OperatingSystem = Opaque<string, 'OperatingSystem'>;
export type CpuArchitecture = Opaque<string, 'CpuArchitecture'>;
export type Cpu = string;
export type CpuCores = number;
export type TargetEnv = string;
export type Memory = number;
export type VirtualMachine = boolean;
export type LinuxKernel = string;
export type LinuxDistro = string;
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 NodeSysInfo = {
cpu: string;
memory: number;
core_count: number;
linux_kernel: string;
linux_distro: string;
is_virtual_machine: boolean;
};
export type BlockDetails = [
BlockNumber,
BlockHash,
Milliseconds,
Timestamp,
Maybe<PropagationTime>
];
export type NodeDetails = [
NodeName,
NodeImplementation,
NodeVersion,
Maybe<Address>,
Maybe<NetworkId>,
OperatingSystem,
CpuArchitecture,
TargetEnv,
undefined,
NodeSysInfo
];
export type NodeStats = [PeerCount, TransactionCount];
export type NodeIO = [Array<Bytes>];
export type NodeHardware = [
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 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'>;
export type Ranking<T> = {
list: Array<[T, number]>;
other: number;
unknown: number;
};
export type Range = [number, number | null];
export type ChainStats = {
version: Maybe<Ranking<string>>;
target_os: Maybe<Ranking<string>>;
target_arch: Maybe<Ranking<string>>;
cpu: Maybe<Ranking<string>>;
core_count: Maybe<Ranking<number>>;
memory: Maybe<Ranking<Range>>;
is_virtual_machine: Maybe<Ranking<boolean>>;
linux_distro: Maybe<Ranking<string>>;
linux_kernel: Maybe<Ranking<string>>;
cpu_hashrate_score: Maybe<Ranking<Range>>;
memory_memcpy_score: Maybe<Ranking<Range>>;
disk_sequential_write_score: Maybe<Ranking<Range>>;
disk_random_write_score: Maybe<Ranking<Range>>;
cpu_vendor: Maybe<Ranking<string>>;
};

100
src/components/Ago.tsx Normal file
View File

@ -0,0 +1,100 @@
import * as React from 'react';
import './Tile.css';
import { timestamp, Types } from '../common';
interface AgoProps {
when: Types.Timestamp;
justTime?: boolean;
}
interface AgoState {
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 class Ago extends React.Component<AgoProps, AgoState> {
public static timeDiff = 0 as Types.Milliseconds;
public state: AgoState;
private agoStr: string;
constructor(props: AgoProps) {
super(props);
this.state = {
now: (timestamp() - Ago.timeDiff) as Types.Timestamp,
};
this.agoStr = this.stringify(props.when, this.state.now);
}
public shouldComponentUpdate(nextProps: AgoProps, nextState: AgoState) {
const nextAgoStr = this.stringify(nextProps.when, nextState.now);
if (this.agoStr !== nextAgoStr) {
this.agoStr = nextAgoStr;
return true;
}
return false;
}
public componentDidMount() {
tickers.set(this, (now) => {
this.setState({
now: (now - Ago.timeDiff) as Types.Timestamp,
});
});
}
public componentWillUnmount() {
tickers.delete(this);
}
public render() {
if (this.props.when === 0) {
return <span>-</span>;
}
return (
<span title={new Date(this.props.when).toUTCString()}>{this.agoStr}</span>
);
}
private stringify(when: number, now: number): string {
const ago = Math.max(now - when, 0) / 1000;
let agoStr: string;
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 agoStr;
}
}

View File

@ -0,0 +1,34 @@
.Chain-content-container {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 148px;
}
.Chain-content {
width: 100%;
min-width: 1350px;
min-height: 100%;
background: linear-gradient(90deg, rgba(28,54,100,1) 0%, rgba(45,87,132,1) 100%);
color: #fff;
box-shadow: rgba(0, 0, 0, 0.5) 0 3px 30px;
}
.Chain-line-separator {
width: 100%;
min-width: 1350px;
height: 40px;
background: #f2e370;
color: #000;
font-size: 15px;
font-weight: 600;
display: flex;
justify-content: center;
align-items: center;
}
.Chain-line-separator > a {
color: #1f4671;
cursor: pointer;
}

View File

@ -0,0 +1,110 @@
import * as React from 'react';
import { Connection } from '../../Connection';
import { Types, Maybe } from '../../common';
import {
State as AppState,
Update as AppUpdate,
StateSettings,
} from '../../state';
import { getHashData } from '../../utils';
import { Header } from './';
import { List, WorldMap, Stats } from '../';
import { Persistent, PersistentObject, PersistentSet } from '../../persist';
import './Chain.css';
export type ChainDisplay = 'list' | 'map' | 'settings' | 'consensus' | 'stats';
interface ChainProps {
appState: Readonly<AppState>;
appUpdate: AppUpdate;
connection: Promise<Connection>;
settings: PersistentObject<StateSettings>;
pins: PersistentSet<Types.NodeName>;
sortBy: Persistent<Maybe<number>>;
}
interface ChainState {
display: ChainDisplay;
}
export class Chain extends React.Component<ChainProps, ChainState> {
constructor(props: ChainProps) {
super(props);
let display: ChainDisplay = 'map';
switch (getHashData().tab) {
case 'list':
display = 'list';
break;
case 'settings':
display = 'settings';
break;
}
this.state = {
display,
};
}
public render() {
const { appState } = this.props;
const { best, finalized, blockTimestamp, blockAverage, nodes } = appState;
const { display: currentTab } = this.state;
return (
<div className="Chain">
<Header
nodesLength={nodes.sorted().length}
best={best}
finalized={finalized}
blockAverage={blockAverage}
blockTimestamp={blockTimestamp}
currentTab={currentTab}
setDisplay={this.setDisplay}
/>
<div className="Chain-content-container">
<div className="Chain-content">{this.renderContent()}</div>
</div>
<div className="Chain-line-separator">
GHOST TestNet is Now Live. Check out the&nbsp;
<a onClick={()=> window.open("https://blog.ghostchain.io/ghost-chain-startup-guide/", "_blank")}>
Quick Startup Guide
</a>.
</div>
</div>
);
}
private renderContent() {
const { display } = this.state;
const { appState, appUpdate, pins, sortBy } = this.props;
if (display === 'list') {
return (
<List
appState={appState}
appUpdate={appUpdate}
pins={pins}
sortBy={sortBy}
/>
);
}
if (display === 'map') {
return <WorldMap appState={appState} />;
}
if (display === 'stats') {
return <Stats appState={appState} />;
}
throw new Error('invalid `display`: ${display}');
}
private setDisplay = (display: ChainDisplay) => {
this.setState({ display });
};
}

View File

@ -0,0 +1,42 @@
.Header {
background: linear-gradient(90deg, rgba(28,54,100,1) 0%, rgba(45,87,132,1) 100%);
}
.Header-container {
margin: 0 5% 0;
min-width: 1222px;
width: 90%;
height: 108px;
display: flex;
justify-content: space-between;
align-items: center;
}
.Header-container > *:hover {
color: #f2e370;
}
.Header-logo {
width: 150px;
height: 100%;
}
.Header-logo > svg {
width: 100%;
height: 100%;
color: #fff;
cursor: pointer;
}
.Header-logo > svg:hover {
color: #f2e370;
}
.Header-tabs {
height: 95%;
text-align: right;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
}

View File

@ -0,0 +1,100 @@
import * as React from 'react';
import { Types, Maybe } from '../../common';
import { formatNumber, secondsWithPrecision } from '../../utils';
import { Tab, ChainDisplay } from './';
import { Tile, Ago, Icon } from '../';
import blockIcon from '../../icons/cube.svg';
import finalizedIcon from '../../icons/cube-alt.svg';
import blockTimeIcon from '../../icons/history.svg';
import lastTimeIcon from '../../icons/watch.svg';
import listIcon from '../../icons/list-alt-regular.svg';
import worldIcon from '../../icons/location.svg';
import settingsIcon from '../../icons/settings.svg';
import statsIcon from '../../icons/graph.svg';
import ghostLogoIcon from '../../icons/ghost-logo.svg';
import './Header.css';
interface HeaderProps {
nodesLength: number;
best: Types.BlockNumber;
finalized: Types.BlockNumber;
blockTimestamp: Types.Timestamp;
blockAverage: Maybe<Types.Milliseconds>;
currentTab: ChainDisplay;
setDisplay: (display: ChainDisplay) => void;
}
export class Header extends React.Component<HeaderProps> {
public shouldComponentUpdate(nextProps: HeaderProps) {
return (
this.props.nodesLength !== nextProps.nodesLength ||
this.props.best !== nextProps.best ||
this.props.finalized !== nextProps.finalized ||
this.props.blockTimestamp !== nextProps.blockTimestamp ||
this.props.blockAverage !== nextProps.blockAverage ||
this.props.currentTab !== nextProps.currentTab
);
}
public render() {
const { nodesLength, best, finalized, blockTimestamp, blockAverage } = this.props;
const { currentTab, setDisplay } = this.props;
return (
<div className="Header">
<div className="Header-container">
<div className="Header-logo" onClick={()=> window.open("https://ghostchain.io/", "_blank")}>
<Icon src={ghostLogoIcon} />
</div>
<div className="Header-data">
<Tile title="Latest Block">
#{formatNumber(best)}
</Tile>
<Tile title="Finalized Block">
#{formatNumber(finalized)}
</Tile>
<Tile title="Average Time">
{blockAverage == null
? '-'
: secondsWithPrecision(blockAverage / 1000)}
</Tile>
<Tile title="Last Block">
<Ago when={blockTimestamp} />
</Tile>
<Tile title="Nodes">
{nodesLength}
</Tile>
</div>
<div className="Header-tabs">
<Tab
icon={worldIcon}
label="Map"
display="map"
tab="map"
current={currentTab}
setDisplay={setDisplay}
/>
<Tab
icon={listIcon}
label="List"
display="list"
tab=""
current={currentTab}
setDisplay={setDisplay}
/>
<Tab
icon={statsIcon}
label="Stats"
display="stats"
tab="stats"
current={currentTab}
setDisplay={setDisplay}
/>
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,24 @@
.Chain-Tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 20px;
height: 32px;
width: 32px;
color: #fff;
cursor: pointer;
border-radius: 40px;
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2);
}
.Chain-Tab:hover {
background: #50759e;
}
.Chain-Tab-on,
.Chain-Tab-on:hover {
background: #f2e370;
color: #1f4671;
}

View File

@ -0,0 +1,35 @@
import * as React from 'react';
import { ChainDisplay } from './';
import { Icon } from '../';
import { setHashData } from '../../utils';
import './Tab.css';
interface TabProps {
label: string;
icon: string;
display: ChainDisplay;
current: string;
tab: string;
setDisplay: (display: ChainDisplay) => void;
}
export class Tab extends React.Component<TabProps> {
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} title={label}>
<Icon src={icon} />
</div>
);
}
private onClick = () => {
const { tab, display, setDisplay } = this.props;
setHashData({ tab });
setDisplay(display);
};
}

View File

@ -0,0 +1,3 @@
export * from './Chain';
export * from './Tab';
export * from './Header';

38
src/components/Filter.css Normal file
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: 'Ubuntu', sans-serif;
font-weight: 300;
}
.Filter .Icon {
position: absolute;
left: 13px;
top: 17px;
font-size: 30px;
}

129
src/components/Filter.tsx Normal file
View File

@ -0,0 +1,129 @@
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';
interface FilterProps {
onChange: (value: Maybe<(node: Node) => boolean>) => void;
}
interface FilterState {
value: string;
}
const ESCAPE_KEY = 27;
export class Filter extends React.Component<FilterProps, FilterState> {
public state = {
value: '',
};
private filterInput: HTMLInputElement;
public componentDidMount() {
window.addEventListener('keyup', this.onWindowKeyUp);
}
public componentWillUnmount() {
window.removeEventListener('keyup', this.onWindowKeyUp);
}
public shouldComponentUpdate(
nextProps: FilterProps,
nextState: FilterState
): 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) => {
// Ignore if control key is being pressed
if (event.ctrlKey) {
return;
}
// Ignore events dispatched to other elements that want to use it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (['INPUT', 'TEXTAREA'].includes((event.target as any)?.tagName)) {
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;
};
}
}

15
src/components/Icon.css Normal file
View File

@ -0,0 +1,15 @@
.Icon {
fill: currentColor;
height: 1em;
width: 1em;
text-align: center;
line-height: 1em;
vertical-align: middle;
display: inline-block;
}
.Icon svg,
.Icon-symbol-root symbol {
width: 1em;
height: 1em;
}

73
src/components/Icon.tsx Normal file
View File

@ -0,0 +1,73 @@
import * as React from 'react';
import './Icon.css';
import { getSVGShadowRoot, W3SVG } from '../utils';
interface IconProps {
src: string;
className?: string;
onClick?: () => void;
}
const symbols = new Map<string, string>();
let symbolId = 0;
// Lazily render the icon in the DOM, so that we can referenced
// it by id using shadow DOM.
function renderShadowIcon(src: string): string {
let symbol = symbols.get(src);
if (!symbol) {
symbol = `icon${symbolId}`;
symbolId += 1;
symbols.set(src, symbol);
fetch(src).then(async (response) => {
const html = await response.text();
const temp = document.createElement('div');
temp.innerHTML = html;
const tempSVG = temp.querySelector('svg') as SVGSVGElement;
const symEl = document.createElementNS(W3SVG, 'symbol');
const viewBox = tempSVG.getAttribute('viewBox');
symEl.setAttribute('id', symbol as string);
if (viewBox) {
symEl.setAttribute('viewBox', viewBox);
}
for (const child of Array.from(tempSVG.childNodes)) {
symEl.appendChild(child);
}
getSVGShadowRoot().appendChild(symEl);
});
}
return symbol;
}
export class Icon extends React.Component<IconProps> {
public props: IconProps;
public shouldComponentUpdate(nextProps: IconProps) {
return (
this.props.src !== nextProps.src ||
this.props.className !== nextProps.className
);
}
public render() {
const { className, onClick, src } = this.props;
const symbol = renderShadowIcon(src);
// Use `href` for a shadow DOM reference to the rendered icon
return (
<svg className={`Icon ${className || ''}`} onClick={onClick}>
<use href={`#${symbol}`} />
</svg>
);
}
}

View File

@ -0,0 +1,46 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip, TooltipCopyCallback } from '../../';
import icon from '../../../icons/file-binary.svg';
export class BlockHashColumn extends React.Component<ColumnProps> {
public static readonly label = 'Block Hash';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'blockhash';
public static readonly sortBy = ({ hash }: Node) => hash || '';
private data: Maybe<string>;
private copy: Maybe<TooltipCopyCallback>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.hash;
}
render() {
const { hash } = this.props.node;
this.data = hash;
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={hash} position="right" copy={this.onCopy} />
<Truncate text={hash} chars={16} />
</td>
);
}
private onCopy = (copy: TooltipCopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { formatNumber } from '../../../utils';
import icon from '../../../icons/cube.svg';
export class BlockNumberColumn extends React.Component<ColumnProps> {
public static readonly label = 'Block';
public static readonly icon = icon;
public static readonly width = 88;
public static readonly setting = 'blocknumber';
public static readonly sortBy = ({ height }: Node) => height;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.height;
}
render() {
const { height } = this.props.node;
this.data = height;
return <td className="Column">{`#${formatNumber(height)}`}</td>;
}
}

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { milliOrSecond } from '../../../utils';
import icon from '../../../icons/dashboard.svg';
export class BlockPropagationColumn extends React.Component<ColumnProps> {
public static readonly label = 'Block Propagation Time';
public static readonly icon = icon;
public static readonly width = 58;
public static readonly setting = 'blockpropagation';
public static readonly sortBy = ({ propagationTime }: Node) =>
propagationTime == null ? Infinity : propagationTime;
private data: Maybe<number>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.propagationTime;
}
render() {
const { propagationTime } = this.props.node;
const print =
propagationTime == null ? '∞' : milliOrSecond(propagationTime);
this.data = propagationTime;
return <td className="Column">{print}</td>;
}
}

View File

@ -0,0 +1,30 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { secondsWithPrecision } from '../../../utils';
import icon from '../../../icons/history.svg';
export class BlockTimeColumn extends React.Component<ColumnProps> {
public static readonly label = 'Block Time';
public static readonly icon = icon;
public static readonly width = 80;
public static readonly setting = 'blocktime';
public static readonly sortBy = ({ blockTime }: Node) =>
blockTime == null ? Infinity : blockTime;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.blockTime;
}
render() {
const { blockTime } = this.props.node;
this.data = blockTime;
return (
<td className="Column">{`${secondsWithPrecision(blockTime / 1000)}`}</td>
);
}
}

View File

@ -0,0 +1,42 @@
.Column {
text-align: left;
padding: 6px 13px;
height: 19px;
position: relative;
white-space: nowrap;
}
.Column-truncate {
position: absolute;
left: 0;
right: 0;
top: 0;
padding: 6px 13px;
overflow: hidden;
text-overflow: ellipsis;
}
.Column-Tooltip {
position: initial !important;
padding: inherit !important;
}
.Column-validator {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
}
.Column-validator:hover {
transform: scale(2);
}
.Column--a {
color: inherit;
text-decoration: none;
}
.Column--a:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,113 @@
import { Types, Maybe, timestamp } from '../../../common';
import { Node } from '../../../state';
import './Column.css';
import {
NameColumn,
ValidatorColumn,
LocationColumn,
NetworkIdColumn,
PeersColumn,
TxsColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
BlockNumberColumn,
BlockHashColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
BlockTimeColumn,
BlockPropagationColumn,
LastBlockColumn,
UptimeColumn,
CpuArchitectureColumn, //extra columns added
CpuColumn,
CpuCoresColumn,
LinuxKernelColumn,
IsVirtualMachineColumn,
MemoryColumn,
OperatingSystemColumn,
VersionColumn,
LinuxDistroColumn,
} from './';
export type Column =
| typeof NameColumn
| typeof ValidatorColumn
| typeof LocationColumn
| typeof NetworkIdColumn
| typeof PeersColumn
| typeof TxsColumn
| typeof UploadColumn
| typeof DownloadColumn
| typeof StateCacheColumn
| typeof BlockNumberColumn
| typeof BlockHashColumn
| typeof FinalizedBlockColumn
| typeof FinalizedHashColumn
| typeof BlockTimeColumn
| typeof BlockPropagationColumn
| typeof LastBlockColumn
| typeof UptimeColumn
| typeof CpuArchitectureColumn
| typeof CpuColumn
| typeof CpuCoresColumn
| typeof LinuxDistroColumn
| typeof LinuxKernelColumn
| typeof IsVirtualMachineColumn
| typeof MemoryColumn
| typeof OperatingSystemColumn
| typeof VersionColumn;
export interface ColumnProps {
node: Node;
}
export function formatBytes(
bytes: number,
stamp: Maybe<Types.Timestamp>
): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB${ago}`;
} else if (bytes >= 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB${ago}`;
} else if (bytes >= 1000) {
return `${(bytes / 1024).toFixed(1)} kB${ago}`;
} else {
return `${bytes} B${ago}`;
}
}
export function formatBandwidth(
bps: number,
stamp: Maybe<Types.Timestamp>
): string {
const ago = stamp ? ` (${formatStamp(stamp)})` : '';
if (bps >= 1024 * 1024) {
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`;
} else if (bps >= 1000) {
return `${(bps / 1024).toFixed(1)} kB/s${ago}`;
} else {
return `${bps | 0} B/s${ago}`;
}
}
export const BANDWIDTH_SCALE = 1024 * 1024;
function formatStamp(stamp: Types.Timestamp): string {
const passed = ((timestamp() - stamp) / 1000) | 0;
const hours = (passed / 3600) | 0;
const minutes = ((passed % 3600) / 60) | 0;
const seconds = passed % 60 | 0;
return hours
? `${hours}h ago`
: minutes
? `${minutes}m ago`
: `${seconds}s ago`;
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class CpuArchitectureColumn extends React.Component<ColumnProps> {
public static readonly label = 'CPU Architecture';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'target_arch';
public static readonly sortBy = ({ target_arch }: Node) => target_arch || '';
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.hash;
}
render() {
const { target_arch } = this.props.node;
this.data = target_arch;
return <td className="Column">{target_arch || '-'}</td>;
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class CpuColumn extends React.Component<ColumnProps> {
public static readonly label = 'CPU Column';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'cpu';
public static readonly sortBy = ({ cpu }: Node) => cpu || 0;
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.cpu;
}
render() {
const { cpu } = this.props.node;
this.data = cpu;
return <td className="Column">{cpu || '-'}</td>;
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class CpuCoresColumn extends React.Component<ColumnProps> {
public static readonly label = 'CPU Cores';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'core_count';
public static readonly sortBy = ({ core_count }: Node) => core_count || 0;
private data: number;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.core_count;
}
render() {
const { core_count } = this.props.node;
this.data = core_count;
return <td className="Column">{core_count || '-'}</td>;
}
}

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { ColumnProps, formatBandwidth, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/cloud-download.svg';
export class DownloadColumn extends React.Component<ColumnProps> {
public static readonly label = 'Download Bandwidth';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'download';
public static readonly sortBy = ({ download }: Node) =>
download.length < 3 ? 0 : download[download.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: ColumnProps) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.download;
}
render() {
const { download, chartstamps } = this.props.node;
this.data = download;
if (download.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBandwidth}
values={download}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { formatNumber } from '../../../utils';
import icon from '../../../icons/cube-alt.svg';
export class FinalizedBlockColumn extends React.Component<ColumnProps> {
public static readonly label = 'Finalized Block';
public static readonly icon = icon;
public static readonly width = 88;
public static readonly setting = 'finalized';
public static readonly sortBy = ({ finalized }: Node) => finalized || 0;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.finalized;
}
render() {
const { finalized } = this.props.node;
this.data = finalized;
return <td className="Column">{`#${formatNumber(finalized)}`}</td>;
}
}

View File

@ -0,0 +1,47 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip, TooltipCopyCallback } from '../../';
import icon from '../../../icons/file-binary.svg';
export class FinalizedHashColumn extends React.Component<ColumnProps> {
public static readonly label = 'Finalized Block Hash';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'finalizedhash';
public static readonly sortBy = ({ finalizedHash }: Node) =>
finalizedHash || '';
private data: Maybe<string>;
private copy: Maybe<TooltipCopyCallback>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.finalizedHash;
}
render() {
const { finalizedHash } = this.props.node;
this.data = finalizedHash;
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={finalizedHash} position="right" copy={this.onCopy} />
<Truncate text={finalizedHash} chars={16} />
</td>
);
}
private onCopy = (copy: TooltipCopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class IsVirtualMachineColumn extends React.Component<ColumnProps> {
public static readonly label = 'Virtual Machine';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'is_virtual_machine';
public static readonly sortBy = ({ is_virtual_machine }: Node) =>
is_virtual_machine || false;
private data: boolean;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.is_virtual_machine;
}
render() {
const { is_virtual_machine } = this.props.node;
this.data = is_virtual_machine;
return <td className="Column">{is_virtual_machine ? 'Yes' : 'No'}</td>;
}
}

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Ago } from '../../';
import icon from '../../../icons/watch.svg';
export class LastBlockColumn extends React.Component<ColumnProps> {
public static readonly label = 'Last Block Time';
public static readonly icon = icon;
public static readonly width = 100;
public static readonly setting = 'blocklasttime';
public static readonly sortBy = ({ blockTimestamp }: Node) =>
blockTimestamp || 0;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.blockTimestamp;
}
render() {
const { blockTimestamp } = this.props.node;
this.data = blockTimestamp;
return (
<td className="Column">
<Ago when={blockTimestamp} />
</td>
);
}
}

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class LinuxDistroColumn extends React.Component<ColumnProps> {
public static readonly label = 'Linux Distro';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'linux_distro';
public static readonly sortBy = ({ linux_distro }: Node) =>
linux_distro || '';
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.linux_distro;
}
render() {
const { linux_distro } = this.props.node;
this.data = linux_distro;
return <td className="Column">{linux_distro || '-'}</td>;
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class LinuxKernelColumn extends React.Component<ColumnProps> {
public static readonly label = 'Linux Kernel';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'linux_kernel';
public static readonly sortBy = ({ linux_kernel }: Node) => linux_kernel || 0;
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.linux_kernel;
}
render() {
const { linux_kernel } = this.props.node;
this.data = linux_kernel;
return <td className="Column">{linux_kernel || '-'}</td>;
}
}

View File

@ -0,0 +1,37 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/location.svg';
export class LocationColumn extends React.Component<ColumnProps> {
public static readonly label = 'Location';
public static readonly icon = icon;
public static readonly width = 140;
public static readonly setting = 'location';
public static readonly sortBy = ({ city }: Node) => city || '';
private data: Maybe<string>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.city;
}
render() {
const { city } = this.props.node;
this.data = city;
if (!city) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Tooltip text={city} position="left" />
<Truncate text={city} chars={14} />
</td>
);
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class MemoryColumn extends React.Component<ColumnProps> {
public static readonly label = 'memory';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'memory';
public static readonly sortBy = ({ memory }: Node) => memory || '';
private data: number;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.memory;
}
render() {
const { memory } = this.props.node;
this.data = memory;
return <td className="Column">{memory || '-'}</td>;
}
}

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Truncate, Tooltip } from '../../';
import icon from '../../../icons/server.svg';
export class NameColumn extends React.Component<ColumnProps> {
public static readonly label = 'Node';
public static readonly icon = icon;
public static readonly setting = null;
public static readonly width = null;
public static readonly sortBy = ({ sortableName }: Node) => sortableName;
public shouldComponentUpdate(nextProps: ColumnProps) {
// Node name only changes when the node does
return this.props.node !== nextProps.node;
}
render() {
const { name } = this.props.node;
return (
<td className="Column">
<Tooltip text={name} position="left" />
<Truncate text={name} />
</td>
);
}
}

View File

@ -0,0 +1,50 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Tooltip, TooltipCopyCallback } from '../../';
import icon from '../../../icons/fingerprint.svg';
export class NetworkIdColumn extends React.Component<ColumnProps> {
public static readonly label = 'Network ID';
public static readonly icon = icon;
public static readonly width = 90;
public static readonly setting = 'networkId';
public static readonly sortBy = ({ networkId }: Node) => networkId || '';
private data: Maybe<string>;
private copy: Maybe<TooltipCopyCallback>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.networkId;
}
render() {
const { networkId } = this.props.node;
this.data = networkId;
if (!networkId) {
return <td className="Column">-</td>;
}
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={networkId} position="left" copy={this.onCopy} />
{networkId}
</td>
);
}
private onCopy = (copy: TooltipCopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class OperatingSystemColumn extends React.Component<ColumnProps> {
public static readonly label = 'OS';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'target_os';
public static readonly sortBy = ({ target_os }: Node) => target_os || '';
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.hash;
}
render() {
const { target_os } = this.props.node;
this.data = target_os;
return <td className="Column">{target_os || '-'}</td>;
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/broadcast.svg';
export class PeersColumn extends React.Component<ColumnProps> {
public static readonly label = 'Peer Count';
public static readonly icon = icon;
public static readonly width = 26;
public static readonly setting = 'peers';
public static readonly sortBy = ({ peers }: Node) => peers;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.peers;
}
render() {
const { peers } = this.props.node;
this.data = peers;
return <td className="Column">{peers}</td>;
}
}

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { ColumnProps, formatBytes, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/git-branch.svg';
export class StateCacheColumn extends React.Component<ColumnProps> {
public static readonly label = 'State Cache Size';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'stateCacheSize';
public static readonly sortBy = ({ stateCacheSize }: Node) =>
stateCacheSize.length < 3 ? 0 : stateCacheSize[stateCacheSize.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: ColumnProps) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.stateCacheSize;
}
render() {
const { stateCacheSize, chartstamps } = this.props.node;
this.data = stateCacheSize;
if (stateCacheSize.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBytes}
values={stateCacheSize}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/inbox.svg';
export class TxsColumn extends React.Component<ColumnProps> {
public static readonly label = 'Transactions in Queue';
public static readonly icon = icon;
public static readonly width = 26;
public static readonly setting = 'txs';
public static readonly sortBy = ({ txs }: Node) => txs;
private data = 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.txs;
}
render() {
const { txs } = this.props.node;
this.data = txs;
return <td className="Column">{txs}</td>;
}
}

View File

@ -0,0 +1,45 @@
import * as React from 'react';
import { ColumnProps, formatBandwidth, BANDWIDTH_SCALE } from './';
import { Node } from '../../../state';
import { Sparkline } from '../../';
import icon from '../../../icons/cloud-upload.svg';
export class UploadColumn extends React.Component<ColumnProps> {
public static readonly label = 'Upload Bandwidth';
public static readonly icon = icon;
public static readonly width = 40;
public static readonly setting = 'upload';
public static readonly sortBy = ({ upload }: Node) =>
upload.length < 3 ? 0 : upload[upload.length - 1];
private data: Array<number> = [];
public shouldComponentUpdate(nextProps: ColumnProps) {
// Diffing by ref, as data is an immutable array
return this.data !== nextProps.node.upload;
}
render() {
const { upload, chartstamps } = this.props.node;
this.data = upload;
if (upload.length < 3) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Sparkline
width={44}
height={16}
stroke={1}
format={formatBandwidth}
values={upload}
stamps={chartstamps}
minScale={BANDWIDTH_SCALE}
/>
</td>
);
}
}

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Ago } from '../../';
import icon from '../../../icons/pulse.svg';
export class UptimeColumn extends React.Component<ColumnProps> {
public static readonly label = 'Node Uptime';
public static readonly icon = icon;
public static readonly width = 58;
public static readonly setting = 'uptime';
public static readonly sortBy = ({ startupTime }: Node) => startupTime || 0;
public shouldComponentUpdate(nextProps: ColumnProps) {
// Uptime only changes when the node does
return this.props.node !== nextProps.node;
}
render() {
const { startupTime } = this.props.node;
if (!startupTime) {
return <td className="Column">-</td>;
}
return (
<td className="Column">
<Ago when={startupTime} justTime={true} />
</td>
);
}
}

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { Maybe } from '../../../common';
import { ColumnProps } from './';
import { Node } from '../../../state';
import { Tooltip, TooltipCopyCallback } from '../../';
import icon from '../../../icons/shield.svg';
export class ValidatorColumn extends React.Component<ColumnProps> {
public static readonly label = 'Validator';
public static readonly icon = icon;
public static readonly width = 16;
public static readonly setting = 'validator';
public static readonly sortBy = ({ validator }: Node) => validator || '';
private data: Maybe<string>;
private copy: Maybe<TooltipCopyCallback>;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.validator;
}
render() {
const { validator } = this.props.node;
this.data = validator;
if (!validator) {
return <td className="Column">-</td>;
}
return (
<td className="Column" onClick={this.onClick}>
<Tooltip text={validator} copy={this.onCopy} />
</td>
);
}
private onCopy = (copy: TooltipCopyCallback) => {
this.copy = copy;
};
private onClick = (event: React.MouseEvent) => {
event.stopPropagation();
if (this.copy != null) {
this.copy();
}
};
}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { ColumnProps } from './';
import { Node } from '../../../state';
import icon from '../../../icons/file-binary.svg';
export class VersionColumn extends React.Component<ColumnProps> {
public static readonly label = 'version';
public static readonly icon = icon;
public static readonly width = 154;
public static readonly setting = 'version';
public static readonly sortBy = ({ version }: Node) => version || '';
private data: string;
public shouldComponentUpdate(nextProps: ColumnProps) {
return this.data !== nextProps.node.version;
}
render() {
const { version } = this.props.node;
this.data = version;
return <td className="Column">{version || '-'}</td>;
}
}

View File

@ -0,0 +1,27 @@
export * from './Column';
export * from './NameColumn';
export * from './ValidatorColumn';
export * from './LocationColumn';
export * from './NetworkIdColumn';
export * from './PeersColumn';
export * from './TxsColumn';
export * from './UploadColumn';
export * from './DownloadColumn';
export * from './StateCacheColumn';
export * from './BlockNumberColumn';
export * from './BlockHashColumn';
export * from './FinalizedBlockColumn';
export * from './FinalizedHashColumn';
export * from './BlockTimeColumn';
export * from './BlockPropagationColumn';
export * from './LastBlockColumn';
export * from './UptimeColumn';
export * from './CpuArchitectureColumn';
export * from './CpuCoresColumn';
export * from './LinuxDistroColumn';
export * from './IsVirtualMachineColumn';
export * from './MemoryColumn';
export * from './CpuColumn';
export * from './OperatingSystemColumn';
export * from './VersionColumn';
export * from './LinuxKernelColumn';

View File

@ -0,0 +1,30 @@
.List-container {
background-color: #345987;
height: 100%;
min-height: calc(100vh - 148px);
}
.List {
/* Prevents the list from auto-scrolling while cascading node
* updates on new block, which helps with performance. */
overflow-anchor: none;
}
.List-no-nodes {
font-size: 30px;
padding-top: 20vh;
text-align: center;
font-weight: 300;
}
.List-padding {
padding: 0;
margin: 0;
}
.List--table {
width: 100%;
background-color: #345987;
border-spacing: 0;
font-family: 'Ubuntu', sans-serif;
}

View File

@ -0,0 +1,205 @@
import * as React from 'react';
import ReactGA from "react-ga4";
import { Types, Maybe } from '../../common';
import { Filter } from '../';
import { State as AppState, Update as AppUpdate, Node } from '../../state';
import { Row, THead } 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';
interface ListProps {
appState: Readonly<AppState>;
appUpdate: AppUpdate;
pins: PersistentSet<Types.NodeName>;
sortBy: Persistent<Maybe<number>>;
}
// Helper for readability, used as `key` prop for each `Row`
// of the `List`, so that we can maximize re-using DOM elements.
type Key = number;
export class List extends React.Component<ListProps> {
public state = {
filter: null,
viewportHeight: viewport().height,
};
private listStart = 0;
private listEnd = 0;
private relativeTop = -1;
private nextKey: Key = 0;
private previousKeys = new Map<Types.NodeId, Key>();
public componentDidMount() {
ReactGA.send({ hitType: "pageview", page: "/list" });
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 { pins, sortBy, appState } = this.props;
const { selectedColumns } = appState;
const { filter } = this.state;
let nodes = 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>
);
}
// With filter present, we can no longer guarantee that focus corresponds
// to rendering view, so we put the whole list in focus
appState.nodes.setFocus(0, nodes.length);
} else {
appState.nodes.setFocus(this.listStart, this.listEnd);
}
const height = TH_HEIGHT + nodes.length * TR_HEIGHT;
const top = this.listStart * TR_HEIGHT;
nodes = nodes.slice(this.listStart, this.listEnd);
const keys = this.recalculateKeys(nodes);
return (
<div className="List-container">
<div className="List" style={{ height }}>
<table className="List--table">
<THead columns={selectedColumns} sortBy={sortBy} />
<tbody>
<tr className="List-padding" style={{ height: `${top}px` }} />
{nodes.map((node, i) => (
<Row
key={keys[i]}
node={node}
pins={pins}
columns={selectedColumns}
/>
))}
</tbody>
</table>
</div>
<Filter onChange={this.onFilterChange} />
</div>
);
}
// Get an array of keys for each `Node` in viewport in order.
//
// * If a `Node` was previously rendered, it will keep its `Key`.
//
// * If a `Node` is new to the viewport, it will get a `Key` of
// another `Node` that was removed from the viewport, or a new one.
private recalculateKeys(nodes: Array<Node>): Array<Key> {
// First we find all keys for `Node`s which didn't change from
// last render.
const keptKeys: Array<Maybe<Key>> = nodes.map(({ id }) => {
const key = this.previousKeys.get(id);
if (key != null) {
this.previousKeys.delete(id);
}
return key;
});
// Array of all unused keys
const unusedKeys = Array.from(this.previousKeys.values());
let search = 0;
// Clear the map so we can set new values
this.previousKeys.clear();
// Filling in blanks and re-populate previousKeys
return keptKeys.map((key: Maybe<Key>, i) => {
const id = nodes[i].id;
// `Node` was previously in viewport
if (key != null) {
this.previousKeys.set(id, key);
return key;
}
// Recycle the next unused key
if (search < unusedKeys.length) {
const unused = unusedKeys[search++];
this.previousKeys.set(id, unused);
return unused;
}
// No unused keys left, generate a new key
const newKey = this.nextKey++;
this.previousKeys.set(id, newKey);
return newKey;
});
}
private onScroll = () => {
const relativeTop = divisibleBy(
window.scrollY - (HEADER + TR_HEIGHT),
TR_HEIGHT * ROW_MARGIN
);
if (this.relativeTop === relativeTop) {
return;
}
this.relativeTop = relativeTop;
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.listStart || listEnd !== this.listEnd) {
this.listStart = listStart;
this.listEnd = listEnd;
this.props.appUpdate({});
}
};
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);
}

View File

@ -0,0 +1,27 @@
.Row {
color: #fff;
cursor: pointer;
}
.Row-pinned td:first-child {
border-left: 5px solid #F2E370;
padding-left: 10px;
}
.Row-pinned td:last-child {
border-right: 5px solid #F2E370;
padding-right: 10px;
}
.Row-pinned {
color: #f2e370 !important;
}
.Row-stale {
color: #fff;
}
.Row:hover {
background-color: #1b3563;
color: #fff;
}

116
src/components/List/Row.tsx Normal file
View File

@ -0,0 +1,116 @@
import * as React from 'react';
import { Types } from '../../common';
import { Node } from '../../state';
import { PersistentSet } from '../../persist';
import {
Column,
NameColumn,
LocationColumn,
NetworkIdColumn,
PeersColumn,
TxsColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
BlockNumberColumn,
BlockHashColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
BlockTimeColumn,
BlockPropagationColumn,
LastBlockColumn,
UptimeColumn,
CpuArchitectureColumn,
CpuColumn,
CpuCoresColumn,
MemoryColumn,
OperatingSystemColumn,
VersionColumn,
IsVirtualMachineColumn,
LinuxDistroColumn,
LinuxKernelColumn,
} from './';
import './Row.css';
interface RowProps {
node: Node;
pins: PersistentSet<Types.NodeName>;
columns: Column[];
}
interface RowState {
update: number;
}
export class Row extends React.Component<RowProps, RowState> {
public static readonly columns: Column[] = [
NameColumn,
LocationColumn,
NetworkIdColumn,
PeersColumn,
TxsColumn,
UploadColumn,
DownloadColumn,
StateCacheColumn,
BlockNumberColumn,
BlockHashColumn,
FinalizedBlockColumn,
FinalizedHashColumn,
BlockTimeColumn,
BlockPropagationColumn,
LastBlockColumn,
UptimeColumn,
VersionColumn,
OperatingSystemColumn,
CpuArchitectureColumn,
CpuColumn,
CpuCoresColumn,
MemoryColumn,
LinuxDistroColumn,
LinuxKernelColumn,
IsVirtualMachineColumn,
];
private renderedChangeRef = 0;
public shouldComponentUpdate(nextProps: RowProps): boolean {
return (
this.props.node.id !== nextProps.node.id ||
this.renderedChangeRef !== nextProps.node.changeRef
);
}
public render() {
const { node, columns } = this.props;
this.renderedChangeRef = node.changeRef;
let className = 'Row';
if (node.pinned) {
className += ' Row-pinned';
}
if (node.stale) {
className += ' Row-stale';
}
return (
<tr className={className} onClick={this.toggle}>
{columns.map((col, index) =>
React.createElement(col, { node, key: index })
)}
</tr>
);
}
public toggle = () => {
const { pins, node } = this.props;
if (node.pinned) {
pins.delete(node.name);
} else {
pins.add(node.name);
}
};
}

View File

@ -0,0 +1,24 @@
.THead {
background: #50759e;
}
.THeadCell {
text-align: left;
padding: 8px 13px;
height: 23px;
}
.THeadCell-sortable {
cursor: pointer;
}
.THeadCell-sorted {
cursor: pointer;
background: #345b87;
color: #fff;
}
.THeadCell-container {
position: relative;
display: inline-block;
}

View File

@ -0,0 +1,48 @@
import * as React from 'react';
import { Maybe } from '../../common';
import { Column, THeadCell } from './';
import { Persistent } from '../../persist';
import './THead.css';
interface THeadProps {
columns: Column[];
sortBy: Persistent<Maybe<number>>;
}
export class THead extends React.Component<THeadProps> {
private sortBy: Maybe<number>;
constructor(props: THeadProps) {
super(props);
this.sortBy = props.sortBy.get();
}
public shouldComponentUpdate(nextProps: THeadProps) {
return this.sortBy !== nextProps.sortBy.get();
}
public render() {
const { columns, sortBy } = this.props;
const last = columns.length - 1;
this.sortBy = sortBy.get();
return (
<thead>
<tr className="THead">
{columns.map((col, index) => (
<THeadCell
key={index}
column={col}
index={index}
last={last}
sortBy={sortBy}
/>
))}
</tr>
</thead>
);
}
}

View File

@ -0,0 +1,63 @@
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';
interface THeadCellProps {
column: Column;
index: number;
last: number;
sortBy: Persistent<Maybe<number>>;
}
export class THeadCell extends React.Component<THeadCellProps> {
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
? 'THeadCell'
: sortBy === index || sortBy === ~index
? 'THeadCell THeadCell-sorted'
: 'THeadCell THeadCell-sortable';
const i =
sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon;
return (
<th
className={className}
style={width ? { width } : undefined}
onClick={this.toggleSort}
>
<span className="THeadCell-container">
<Tooltip text={label} position={position} />
<Icon src={i} />
</span>
</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);
}
};
}

View File

@ -0,0 +1,5 @@
export * from './Column';
export * from './List';
export * from './Row';
export * from './THeadCell';
export * from './THead';

View File

@ -0,0 +1,156 @@
.Location, .Validator-Location {
width: 6px;
height: 6px;
background: transparent;
border: 2px solid #f2e370;
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,
width 0.25s linear,
height 0.25s linear,
background 0.25s linear;
}
.Validator-Location {
border: 2px solid #60c45b;
}
.Location-dimmed {
z-index: 1;
border-color: #fff8c5;
}
.Location-dimmed-validator {
z-index: 1;
border-color: #b9ecb6;
}
.Location-ping {
pointer-events: none;
position: absolute;
display: none;
}
.Location-synced {
z-index: 3;
border-color: #f2e370;
}
.Location-synced-validator {
z-index: 3;
border-color: #60C45B;
}
.Location-synced .Location-ping {
border: 1px solid #f2e370;
border-radius: 30px;
display: block;
animation: ping 1s forwards;
}
.Location-synced-validator .Location-ping {
border: 1px solid #60C45B;
border-radius: 30px;
display: block;
animation: ping-validator 1s forwards;
}
.Location:hover, .Validator-Location:hover {
z-index: 4;
border-color: #fff;
width: 7px;
height: 7px;
background: #fff;
}
.Location-details {
min-width: 335px;
position: absolute;
font-family: 'Ubuntu', sans-serif;
background: #50759e;
border: 1px solid #fff;
border-radius: 5px;
color: #fff;
box-shadow: 0 3px 20px rgba(0, 0, 0, 0.5);
}
.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(242, 227, 112, 1);
}
to {
left: -18px;
top: -18px;
width: 40px;
height: 40px;
border-width: 1px;
border-color: rgba(242, 227, 112, 0);
}
}
@keyframes ping-validator {
from {
left: -1px;
top: -1px;
width: 6px;
height: 6px;
border-width: 1px;
border-color: rgba(96, 196, 91, 1);
}
to {
left: -18px;
top: -18px;
width: 40px;
height: 40px;
border-width: 1px;
border-color: rgba(96, 196, 91, 0);
}
}

View File

@ -0,0 +1,167 @@
import * as React from 'react';
import {
formatNumber,
trimHash,
milliOrSecond,
secondsWithPrecision,
} from '../../utils';
import { Ago, Icon } from '../';
import { Node } from '../../state';
import nodeIcon from '../../icons/server.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 type LocationQuarter = 0 | 1 | 2 | 3;
export interface LocationOffsets {
leftOffset: number;
topOffset: number;
}
interface LocationProps {
node: Node;
position: LocationPosition;
focused: boolean;
}
export interface LocationPosition {
left: number;
top: number;
quarter: LocationQuarter;
}
interface LocationState {
hover: boolean;
}
export class Location extends React.Component<LocationProps, LocationState> {
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';
}
if (node.validator) {
className = `Validator-${className}-validator`;
}
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;
return (
<table className="Location-details Location-details">
<tbody>
<tr>
<td title="Node">
<Icon src={nodeIcon} />
</td>
<td colSpan={5}>{name}</td>
</tr>
<tr>
<td title="Implementation">
<Icon src={nodeTypeIcon} />
</td>
<td colSpan={5}>
{implementation} v{version}
</td>
</tr>
<tr>
<td title="Location">
<Icon src={nodeLocationIcon} />
</td>
<td colSpan={5}>{city}</td>
</tr>
<tr>
<td title="Block">
<Icon src={blockIcon} />
</td>
<td colSpan={5}>#{formatNumber(height)}</td>
</tr>
<tr>
<td title="Block Hash">
<Icon src={blockHashIcon} />
</td>
<td colSpan={5}>{trimHash(hash, 20)}</td>
</tr>
<tr>
<td title="Block Time">
<Icon src={blockTimeIcon} />
</td>
<td style={{ width: 80 }}>
{secondsWithPrecision(blockTime / 1000)}
</td>
<td title="Block Propagation Time">
<Icon src={propagationTimeIcon} />
</td>
<td style={{ width: 58 }}>
{propagationTime == null ? '∞' : milliOrSecond(propagationTime)}
</td>
<td title="Last Block Time">
<Icon src={lastTimeIcon} />
</td>
<td style={{ minWidth: 82 }}>
<Ago when={blockTimestamp} />
</td>
</tr>
</tbody>
</table>
);
}
private onMouseOver = () => {
this.setState({ hover: true });
};
private onMouseOut = () => {
this.setState({ hover: false });
};
}

View File

@ -0,0 +1,18 @@
.Map-container {
background: linear-gradient(90deg, rgba(28,54,100,1) 0%, rgba(45,87,132,1) 100%);
}
.Map {
background: url('../../assets/world-map.svg') no-repeat;
background-size: contain;
}
.Map-container, .Map {
min-width: 1350px;
background-position: center;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}

164
src/components/Map/Map.tsx Normal file
View File

@ -0,0 +1,164 @@
import * as React from 'react';
import ReactGA from "react-ga4";
import { Types, Maybe } from '../../common';
import { Filter } from '../';
import { State as AppState, Node } from '../../state';
import { Location, LocationQuarter, LocationPosition, LocationOffsets } from './';
import { viewport } from '../../utils';
const MAP_RATIO = 800 / 350;
const MAP_HEIGHT_ADJUST = 400 / 350;
const HEADER = 148;
import './Map.css';
interface MapProps {
appState: Readonly<AppState>;
}
interface MapState {
filter: Maybe<(node: Node) => boolean>;
width: number;
height: number;
top: number;
left: number;
}
export class WorldMap extends React.Component<MapProps, MapState> {
public state: MapState = {
filter: null,
width: 0,
height: 0,
top: 0,
left: 0,
};
public offsets: Map<Types.NetworkId, LocationOffsets> = new Map<Types.NetworkId, LocationOffsets>();
public usedLeftPositions: Set<number> = new Set<number>();
public usedTopPositions: Set<number> = new Set<number>();
public componentDidMount() {
ReactGA.send({ hitType: "pageview", page: "/map" });
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 (
<div className="Map-container">
<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(node.networkId, lat, lon);
return (
<Location
key={node.id}
position={position}
focused={focused}
node={node}
/>
);
})}
</div>
<Filter onChange={this.onFilterChange} />
</div>
);
}
private pixelPosition(
id: Maybe<Types.NetworkId>,
lat: Types.Latitude,
lon: Types.Longitude,
): LocationPosition {
const { state, offsets, usedLeftPositions, usedTopPositions } = this;
// Longitude ranges -180 (west) to +180 (east)
// Latitude ranges +90 (north) to -90 (south)
let left = Math.round(((180 + lon) / 360) * state.width + state.left);
let top = Math.round(
((90 - lat) / 180) * state.height * MAP_HEIGHT_ADJUST + state.top
);
let quarter: LocationQuarter = 0;
if (lon > 0) {
quarter = (quarter | 1) as LocationQuarter;
}
if (lat < 0) {
quarter = (quarter | 2) as LocationQuarter;
}
let topOffset = 0;
let leftOffset = 0;
id = id!;
const maybeOffsets = offsets.get(id);
if (maybeOffsets) {
topOffset = maybeOffsets.topOffset;
leftOffset = maybeOffsets.leftOffset;
} else if (usedLeftPositions.has(left) && usedTopPositions.has(top)) {
topOffset = Math.floor(Math.random() * 12) - 6;
leftOffset = Math.floor(Math.random() * 12) - 6;
}
left += leftOffset;
top += topOffset;
offsets.set(id, { leftOffset, topOffset });
usedLeftPositions.add(left);
usedTopPositions.add(top);
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 });
};
}

View File

@ -0,0 +1,2 @@
export * from './Map';
export * from './Location';

View File

@ -0,0 +1,18 @@
.Sparkline {
fill: currentcolor;
fill-opacity: 0.35;
stroke: currentcolor;
margin: 0 -1px -3px -1px;
}
.Sparkline path {
pointer-events: none;
}
.Sparkline .Sparkline-cursor {
display: none;
}
.Sparkline:hover .Sparkline-cursor {
display: initial;
}

View File

@ -0,0 +1,101 @@
import * as React from 'react';
import { Types, Maybe } from '../common';
import { Tooltip, TooltipUpdateCallback } from './';
import './Sparkline.css';
interface SparklineProps {
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<SparklineProps> {
private cursor: SVGPathElement;
private update: TooltipUpdateCallback;
public shouldComponentUpdate(nextProps: SparklineProps): boolean {
const { stroke, width, height, format, values } = this.props;
return (
values !== nextProps.values ||
stroke !== nextProps.stroke ||
width !== nextProps.width ||
height !== nextProps.height ||
format !== nextProps.format
);
}
public render() {
const { stroke, width, height, minScale, values } = this.props;
const padding = stroke / 2;
const paddedHeight = height - padding;
const paddedWidth = width - 2;
const max = Math.max(minScale || 0, ...values);
const offset = paddedWidth / (values.length - 1);
let path = '';
values.forEach((value, index) => {
const x = 1 + index * offset;
const y = padding + (1 - value / max) * paddedHeight;
if (path) {
path += ` L ${x} ${y}`;
} else {
path = `${x} ${y}`;
}
});
return (
<>
<Tooltip text="-" onInit={this.onTooltipInit} />
<svg
className="Sparkline"
width={width}
height={height}
strokeWidth={stroke}
onMouseMove={this.onMouseMove}
onMouseLeave={this.onMouseLeave}
>
<path d={`M 0 ${height} L ${path} V ${height} Z`} stroke="none" />
<path d={`M ${path}`} fill="none" />
<path className="Sparkline-cursor" strokeWidth="2" ref={this.onRef} />
</svg>
</>
);
}
private onRef = (cursor: SVGPathElement) => {
this.cursor = cursor;
};
private onTooltipInit = (update: TooltipUpdateCallback) => {
this.update = update;
};
private onMouseMove = (
event: React.MouseEvent<SVGSVGElement, MouseEvent>
) => {
const { width, height, values, format, stamps } = this.props;
const offset = (width - 2) / (values.length - 1);
const cur =
Math.round((event.nativeEvent.offsetX - 1 - offset / 2) / offset) | 0;
this.cursor.setAttribute('d', `M ${1 + offset * cur} 0 V ${height}`);
const str = format
? format(values[cur], stamps ? stamps[cur] : null)
: `${values[cur]}`;
this.update(str);
};
private onMouseLeave = () => {
this.cursor.removeAttribute('d');
};
}

View File

@ -0,0 +1,56 @@
.Stats {
text-align: center;
padding-top: 2.5rem;
padding-bottom: 0.1rem;
}
.Stats-category {
text-align: left;
background-color: #345987;
margin-bottom: 2.5rem;
padding: 1rem;
}
.Stats-category table {
color: #fff;
width: 100%;
table-layout: fixed;
border-collapse: collapse;
}
.Stats-category tbody > tr:nth-child(odd) {
background-color: #50759f;
}
.Stats-percent {
width: 6em;
text-align: right;
padding-left: 0.5rem;
padding-right: 1rem;
}
.Stats-count {
width: 6.5em;
text-align: right;
padding-right: 1.5rem;
border-right: 1px solid #fff;
}
.Stats-value {
padding-left: 2rem;
}
th.Stats-value {
padding-left: 1rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.Stats-category td {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.Stats-unknown {
opacity: 0.5;
}

View File

@ -0,0 +1,189 @@
import * as React from 'react';
import ReactGA from "react-ga4";
import { Maybe } from '../../common';
import { State as AppState } from '../../state';
import { Ranking, Range } from '../../common/types';
import './Stats.css';
interface StatsProps {
appState: Readonly<AppState>;
}
function displayPercentage(percent: number): string {
return (Math.round(percent * 100) / 100).toFixed(2);
}
function generateRankingTable<T>(
key: string,
label: string,
format: (value: T) => string,
ranking: Ranking<T>
) {
let total = ranking.other + ranking.unknown;
ranking.list.forEach(([_, count]) => {
total += count;
});
if (ranking.unknown === total) {
return null;
}
const entries: React.ReactNode[] = [];
ranking.list.forEach(([value, count]) => {
const percent = displayPercentage((count / total) * 100);
const index = entries.length;
entries.push(
<tr key={index}>
<td className="Stats-percent">{percent}%</td>
<td className="Stats-count">{count}</td>
<td className="Stats-value">{format(value)}</td>
</tr>
);
});
if (ranking.other > 0) {
const percent = displayPercentage((ranking.other / total) * 100);
entries.push(
<tr key="other">
<td className="Stats-percent">{percent}%</td>
<td className="Stats-count">{ranking.other}</td>
<td className="Stats-value">Other</td>
</tr>
);
}
if (ranking.unknown > 0) {
const percent = displayPercentage((ranking.unknown / total) * 100);
entries.push(
<tr key="unknown">
<td className="Stats-percent">{percent}%</td>
<td className="Stats-count">{ranking.unknown}</td>
<td className="Stats-value Stats-unknown">Unknown</td>
</tr>
);
}
return (
<div className="Stats-category" key={key}>
<table>
<thead>
<tr>
<th className="Stats-percent" />
<th className="Stats-count" />
<th className="Stats-value">{label}</th>
</tr>
</thead>
<tbody>{entries}</tbody>
</table>
</div>
);
}
function identity(value: string | number): string {
return value + '';
}
function formatMemory(value: Range): string {
const [min, max] = value;
if (min === 0) {
return 'Less than ' + max + ' GB';
}
if (max === null) {
return 'At least ' + min + ' GB';
}
return min + ' GB';
}
function formatYesNo(value: boolean): string {
if (value) {
return 'Yes';
} else {
return 'No';
}
}
function formatScore(value: Range): string {
const [min, max] = value;
if (max === null) {
return 'More than ' + (min / 100).toFixed(1) + 'x';
}
if (min === 0) {
return 'Less than ' + (max / 100).toFixed(1) + 'x';
}
if (min <= 100 && max >= 100) {
return 'Baseline';
}
return (min / 100).toFixed(1) + 'x';
}
export class Stats extends React.Component<StatsProps> {
public componentDidMount() {
ReactGA.send({ hitType: "pageview", page: "/stats" });
}
public render() {
const { appState } = this.props;
const children: React.ReactNode[] = [];
function add<T>(
key: string,
label: string,
format: (value: T) => string,
ranking: Maybe<Ranking<T>>
) {
if (ranking) {
const child = generateRankingTable(key, label, format, ranking);
if (child !== null) {
children.push(child);
}
}
}
const stats = appState.chainStats;
if (stats) {
add('version', 'Version', identity, stats.version);
add('target_os', 'Operating System', identity, stats.target_os);
add('target_arch', 'CPU Architecture', identity, stats.target_arch);
add('cpu', 'CPU', identity, stats.cpu);
add('core_count', 'CPU Cores', identity, stats.core_count);
add('cpu_vendor', 'CPU Vendor', identity, stats.cpu_vendor);
add('memory', 'Memory', formatMemory, stats.memory);
add(
'is_virtual_machine',
'Is Virtual Machine?',
formatYesNo,
stats.is_virtual_machine
);
add('linux_distro', 'Linux Distribution', identity, stats.linux_distro);
add('linux_kernel', 'Linux Kernel', identity, stats.linux_kernel);
add(
'cpu_hashrate_score',
'CPU Speed',
formatScore,
stats.cpu_hashrate_score
);
add(
'memory_memcpy_score',
'Memory Speed',
formatScore,
stats.memory_memcpy_score
);
add(
'disk_sequential_write_score',
'Disk Speed (sequential writes)',
formatScore,
stats.disk_sequential_write_score
);
add(
'disk_random_write_score',
'Disk Speed (random writes)',
formatScore,
stats.disk_random_write_score
);
}
return <div className="Stats">{children}</div>;
}
}

View File

@ -0,0 +1 @@
export * from './Stats';

43
src/components/Tile.css Normal file
View File

@ -0,0 +1,43 @@
.Tile {
font-size: 2.5em;
text-align: left;
width: 208px;
height: 100px;
display: inline-block;
position: relative;
color: #fff;
cursor: pointer;
}
.Tile:hover {
color: #f2e370;
}
.Tile-label {
position: absolute;
top: 24px;
left: 50px;
right: 0;
font-size: 0.4em;
text-transform: uppercase;
}
.Tile-content {
position: absolute;
bottom: 16px;
left: 50px;
right: 0;
font-weight: 300;
font-size: 0.75em;
}
.Tile .Icon {
position: absolute;
left: 20px;
top: 15px;
font-size: 0.8em;
padding: 0.1em;
border-radius: 1.25em;
border: 2px solid #e6007a;
color: #e6007a;
}

17
src/components/Tile.tsx Normal file
View File

@ -0,0 +1,17 @@
import * as React from 'react';
import './Tile.css';
import { Icon } from './Icon';
interface TileProps {
title: string;
children?: React.ReactNode;
}
export function Tile(props: TileProps) {
return (
<div className="Tile">
<span className="Tile-label">{props.title}</span>
<span className="Tile-content">{props.children}</span>
</div>
);
}

View File

@ -0,0 +1,80 @@
.Tooltip {
background: #000;
color: #fff;
font-family: 'Ubuntu', sans-serif;
font-size: 13px;
font-weight: 400;
padding: 3px 5px;
border-radius: 2px;
background: #1b3563;
position: absolute;
white-space: nowrap;
top: -32px;
left: 50%;
transform: translateX(-50%);
display: none;
box-shadow: 0 3px 20px rgba(255, 255, 255, 0.2);
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 #1b3563 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: #fff;
}
.Tooltip-container {
position: relative;
}
.Tooltip-container-inline {
display: inline-block;
}
.Tooltip-container-inline .Tooltip-left {
left: 0;
}
.Tooltip-container-inline .Tooltip-right {
right: 0;
}
:hover > .Tooltip {
display: block;
}

View File

@ -0,0 +1,96 @@
import * as React from 'react';
import { Maybe } from '../common';
import './Tooltip.css';
interface TooltipProps {
text: string;
copy?: (cb: TooltipCopyCallback) => void;
position?: 'left' | 'right' | 'center';
onInit?: (update: TooltipUpdateCallback) => void;
}
export type TooltipUpdateCallback = (text: string) => void;
export type TooltipCopyCallback = Maybe<() => 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 function Tooltip({
text,
position,
copy,
onInit,
}: TooltipProps): JSX.Element {
const [copied, setCopied] = React.useState<boolean>(false);
const [timer, setTimer] = React.useState<NodeJS.Timer | null>(null);
const el = React.useRef<HTMLDivElement>(null);
const update = React.useCallback(
(newText: string) => {
if (el.current) {
el.current.textContent = newText;
}
},
[el]
);
function restore() {
setCopied(false);
setTimer(null);
}
function onClick() {
copyToClipboard(text);
if (timer) {
clearTimeout(timer);
}
setCopied(true);
setTimer(setTimeout(restore, 2000));
}
React.useEffect(() => {
if (onInit) {
onInit(update);
}
if (copy) {
copy(onClick);
}
return () => {
if (timer) {
clearTimeout(timer);
}
if (copy) {
copy(null);
}
};
}, []);
let tooltipClass = 'Tooltip';
if (position && position !== 'center') {
tooltipClass += ` Tooltip-${position}`;
}
if (copied) {
tooltipClass += ' Tooltip-copied';
}
return (
<div className={tooltipClass} ref={el}>
{copied ? 'Copied to clipboard!' : text}
</div>
);
}

View File

@ -0,0 +1,30 @@
import * as React from 'react';
interface TruncateProps {
text: string;
chars?: number;
}
export class Truncate extends React.Component<TruncateProps> {
public shouldComponentUpdate(nextProps: TruncateProps): boolean {
return this.props.text !== nextProps.text;
}
public render() {
const { text, chars } = this.props;
if (!text) {
return '-';
}
if (chars != null && text.length <= chars) {
return text;
}
return chars ? (
`${text.substr(0, chars - 1)}`
) : (
<div className="Column-truncate">{text}</div>
);
}
}

11
src/components/index.ts Normal file
View File

@ -0,0 +1,11 @@
export * from './Chain';
export * from './List';
export * from './Map';
export * from './Stats';
export * from './Icon';
export * from './Tile';
export * from './Ago';
export * from './Sparkline';
export * from './Truncate';
export * from './Tooltip';
export * from './Filter';

1
src/icons/alert.svg Normal file
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
src/icons/archive.svg Normal file
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

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