initial commit for public repo
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
1
.env.template
Normal file
@ -0,0 +1 @@
|
|||||||
|
REACT_APP_TRACKING_ID=
|
40
.eslintrc.json
Normal 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
@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.nyc
|
||||||
|
.nyc_output
|
||||||
|
node_modules/
|
||||||
|
build/
|
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true
|
||||||
|
}
|
5
assets/favicon.svg
Normal 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
@ -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
After Width: | Height: | Size: 217 KiB |
3
assets/mock.image.js
Normal 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
@ -0,0 +1,2 @@
|
|||||||
|
// For loading styles, give back an empty object in tests:
|
||||||
|
module.exports = {};
|
3
images.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare module '*.svg';
|
||||||
|
declare module '*.png';
|
||||||
|
declare module '*.jpg';
|
11
jest.config.js
Normal 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
69
package.json
Normal 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
@ -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
@ -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
@ -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
@ -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…
|
||||||
|
</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
@ -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>;
|
1
src/assets/ghost-logo.svg
Normal file
After Width: | Height: | Size: 7.3 KiB |
42
src/assets/inner-circle.svg
Normal 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 |
16
src/assets/inner-circle_old.svg
Normal 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 |
43
src/assets/outer-circle.svg
Normal 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
After Width: | Height: | Size: 323 KiB |
240
src/common/SortedCollection.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
34
src/components/Chain/Chain.css
Normal 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;
|
||||||
|
}
|
110
src/components/Chain/Chain.tsx
Normal 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
|
||||||
|
<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 });
|
||||||
|
};
|
||||||
|
}
|
42
src/components/Chain/Header.css
Normal 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;
|
||||||
|
}
|
100
src/components/Chain/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
24
src/components/Chain/Tab.css
Normal 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;
|
||||||
|
}
|
35
src/components/Chain/Tab.tsx
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
3
src/components/Chain/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './Chain';
|
||||||
|
export * from './Tab';
|
||||||
|
export * from './Header';
|
38
src/components/Filter.css
Normal 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
@ -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
@ -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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
46
src/components/List/Column/BlockHashColumn.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
27
src/components/List/Column/BlockNumberColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
31
src/components/List/Column/BlockPropagationColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
30
src/components/List/Column/BlockTimeColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
42
src/components/List/Column/Column.css
Normal 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;
|
||||||
|
}
|
113
src/components/List/Column/Column.tsx
Normal 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`;
|
||||||
|
}
|
26
src/components/List/Column/CpuArchitectureColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/CpuColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/CpuCoresColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
45
src/components/List/Column/DownloadColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
27
src/components/List/Column/FinalizedBlockColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
47
src/components/List/Column/FinalizedHashColumn.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
27
src/components/List/Column/IsVirtualMachineColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
32
src/components/List/Column/LastBlockColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
27
src/components/List/Column/LinuxDistroColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/LinuxKernelColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
37
src/components/List/Column/LocationColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/MemoryColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
29
src/components/List/Column/NameColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
50
src/components/List/Column/NetworkIdColumn.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
26
src/components/List/Column/OperatingSystemColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/PeersColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
45
src/components/List/Column/StateCacheColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
26
src/components/List/Column/TxsColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
45
src/components/List/Column/UploadColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
src/components/List/Column/UptimeColumn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
49
src/components/List/Column/ValidatorColumn.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
26
src/components/List/Column/VersionColumn.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
27
src/components/List/Column/index.ts
Normal 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';
|
30
src/components/List/List.css
Normal 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;
|
||||||
|
}
|
205
src/components/List/List.tsx
Normal 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);
|
||||||
|
}
|
27
src/components/List/Row.css
Normal 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
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
24
src/components/List/THead.css
Normal 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;
|
||||||
|
}
|
48
src/components/List/THead.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
63
src/components/List/THeadCell.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
5
src/components/List/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './Column';
|
||||||
|
export * from './List';
|
||||||
|
export * from './Row';
|
||||||
|
export * from './THeadCell';
|
||||||
|
export * from './THead';
|
156
src/components/Map/Location.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
167
src/components/Map/Location.tsx
Normal 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 });
|
||||||
|
};
|
||||||
|
}
|
18
src/components/Map/Map.css
Normal 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
@ -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 });
|
||||||
|
};
|
||||||
|
}
|
2
src/components/Map/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './Map';
|
||||||
|
export * from './Location';
|
18
src/components/Sparkline.css
Normal 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;
|
||||||
|
}
|
101
src/components/Sparkline.tsx
Normal 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');
|
||||||
|
};
|
||||||
|
}
|
56
src/components/Stats/Stats.css
Normal 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;
|
||||||
|
}
|
189
src/components/Stats/Stats.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
1
src/components/Stats/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './Stats';
|
43
src/components/Tile.css
Normal 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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
80
src/components/Tooltip.css
Normal 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;
|
||||||
|
}
|
96
src/components/Tooltip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
30
src/components/Truncate.tsx
Normal 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
@ -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
@ -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
@ -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 |