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 |