diff --git a/packages/common/src/SortedCollection.ts b/packages/common/src/SortedCollection.ts index a5e9680..a7f5016 100644 --- a/packages/common/src/SortedCollection.ts +++ b/packages/common/src/SortedCollection.ts @@ -90,8 +90,7 @@ export namespace SortedCollection { } export class SortedCollection { - private readonly compare: Compare; - + private compare: Compare; private map = Array>(); private list = Array(); private changeRef = 0; @@ -100,6 +99,13 @@ export class SortedCollection { this.compare = compare; } + public setComparator(compare: Compare) { + this.compare = compare; + this.list = this.map.filter((item) => item != null) as Item[]; + this.list.sort(compare); + this.changeRef += 1; + } + public ref(): SortedCollection.StateRef { return this.changeRef as SortedCollection.StateRef; } @@ -110,6 +116,9 @@ export class SortedCollection { this.map = this.map.concat(Array>(Math.max(10, 1 + item.id - this.map.length))); } + // Remove old item if overriding + this.remove(item.id); + this.map[item.id] = item; sortedInsert(item, this.list, this.compare); @@ -169,6 +178,14 @@ export class SortedCollection { } } + 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); } diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 3df76a0..b1cd830 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import { Types, SortedCollection } from '@dotstats/common'; +import { Types, SortedCollection, Maybe, Compare } from '@dotstats/common'; import { AllChains, Chains, Chain, Ago, OfflineIndicator } from './components'; +import { Row, Column } from './components/List'; import { Connection } from './Connection'; -import { PersistentObject, PersistentSet } from './persist'; +import { Persistent, PersistentObject, PersistentSet } from './persist'; import { State, Node, ChainData, PINNED_CHAIN } from './state'; import { getHashData } from './utils'; import stable from 'stable'; @@ -14,6 +15,7 @@ export default class App extends React.Component<{}, State> { private chainsCache: ChainData[] = []; private readonly settings: PersistentObject; private readonly pins: PersistentSet; + private readonly sortBy: Persistent>; private readonly connection: Promise; constructor(props: {}) { @@ -42,7 +44,12 @@ export default class App extends React.Component<{}, State> { uptime: false, networkstate: false, }, - (settings) => this.setState({ settings }) + (settings) => { + const selectedColumns = this.selectedColumns(settings); + + this.sortBy.set(null); + this.setState({ settings, selectedColumns, sortBy: null }) + }, ); this.pins = new PersistentSet('pinned_names', (pins) => { @@ -53,6 +60,13 @@ export default class App extends React.Component<{}, State> { this.setState({ nodes, pins }); }); + this.sortBy = new Persistent>('sortBy', null, (sortBy) => { + const compare = this.getComparator(sortBy); + + this.state.nodes.setComparator(compare); + this.setState({ sortBy }); + }); + const { tab = '' } = getHashData(); this.state = { @@ -72,9 +86,13 @@ export default class App extends React.Component<{}, State> { nodes: new SortedCollection(Node.compare), settings: this.settings.raw(), pins: this.pins.get(), + sortBy: this.sortBy.get(), + selectedColumns: this.selectedColumns(this.settings.raw()), tab, }; + this.state.nodes.setComparator(this.getComparator(this.sortBy.get())); + this.connection = Connection.create(this.pins, (changes) => { if (changes) { this.setState(changes); @@ -109,7 +127,7 @@ export default class App extends React.Component<{}, State> {
- + {overlay}
); @@ -181,4 +199,35 @@ export default class App extends React.Component<{}, State> { return this.chainsCache; } + private selectedColumns(settings: State.Settings): Column[] { + return Row.columns.filter(({ setting }) => setting == null || settings[setting]); + } + + private getComparator(sortBy: Maybe): Compare { + const columns = this.state.selectedColumns; + + if (sortBy != null) { + const [index, rev] = sortBy < 0 ? [~sortBy, -1] : [sortBy, 1]; + const column = columns[index]; + + if (column != null && column.sortBy) { + const key = column.sortBy; + + return (a, b) => { + const aKey = key(a); + const bKey = key(b); + + if (aKey < bKey) { + return -1 * rev; + } else if (aKey > bKey) { + return 1 * rev; + } else { + return Node.compare(a, b); + } + } + } + } + + return Node.compare; + } } diff --git a/packages/frontend/src/Connection.ts b/packages/frontend/src/Connection.ts index fa833b7..74bcaad 100644 --- a/packages/frontend/src/Connection.ts +++ b/packages/frontend/src/Connection.ts @@ -4,6 +4,7 @@ import { PersistentSet } from './persist'; import { getHashData, setHashData } from './utils'; import { AfgHandling } from './AfgHandling'; import { VIS_AUTHORITIES_LIMIT } from '../../frontend/src/components/Consensus'; +import { Column } from './components/List'; const { Actions } = FeedMessage; @@ -117,13 +118,19 @@ export class Connection { } public handleMessages = (messages: FeedMessage.Message[]) => { - const { nodes, chains } = this.state; + const { nodes, chains, sortBy, selectedColumns } = this.state; const ref = nodes.ref(); const updateState: UpdateBound = (state) => { this.state = this.update(state); }; const getState = () => this.state; const afg = new AfgHandling(updateState, getState); + let sortByColumn: Maybe = null; + + if (sortBy != null) { + sortByColumn = sortBy < 0 ? selectedColumns[~sortBy] : selectedColumns[sortBy]; + } + for (const message of messages) { switch (message.action) { case Actions.FeedVersion: { @@ -187,7 +194,7 @@ export class Connection { case Actions.LocatedNode: { const [id, lat, lon, city] = message.payload; - nodes.mut(id, (node) => node.updateLocation([lat, lon, city])); + nodes.mutAndMaybeSort(id, (node) => node.updateLocation([lat, lon, city]), sortByColumn === Column.LOCATION); break; } @@ -203,7 +210,11 @@ export class Connection { case Actions.FinalizedBlock: { const [id, height, hash] = message.payload; - nodes.mut(id, (node) => node.updateFinalized(height, hash)); + nodes.mutAndMaybeSort( + id, + (node) => node.updateFinalized(height, hash), + sortByColumn === Column.FINALIZED || sortByColumn === Column.FINALIZED_HASH, + ); break; } @@ -211,7 +222,11 @@ export class Connection { case Actions.NodeStats: { const [id, nodeStats] = message.payload; - nodes.mut(id, (node) => node.updateStats(nodeStats)); + nodes.mutAndMaybeSort( + id, + (node) => node.updateStats(nodeStats), + sortByColumn === Column.PEERS || sortByColumn === Column.TXS, + ); break; } @@ -219,7 +234,11 @@ export class Connection { case Actions.NodeHardware: { const [id, nodeHardware] = message.payload; - nodes.mut(id, (node) => node.updateHardware(nodeHardware)); + nodes.mutAndMaybeSort( + id, + (node) => node.updateHardware(nodeHardware), + sortByColumn === Column.CPU || sortByColumn === Column.MEM || sortByColumn === Column.UPLOAD || sortByColumn === Column.DOWNLOAD, + ); break; } diff --git a/packages/frontend/src/components/Chain/Chain.tsx b/packages/frontend/src/components/Chain/Chain.tsx index 0c8db16..1d632f4 100644 --- a/packages/frontend/src/components/Chain/Chain.tsx +++ b/packages/frontend/src/components/Chain/Chain.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { Connection } from '../../Connection'; -import { Types } from '@dotstats/common'; +import { Types, Maybe } from '@dotstats/common'; import { State as AppState } from '../../state'; import { formatNumber, secondsWithPrecision, getHashData } from '../../utils'; import { Tab } from './'; import { Tile, Ago, List, Map, Settings, Consensus } from '../'; -import { PersistentObject, PersistentSet } from '../../persist'; +import { Persistent, PersistentObject, PersistentSet } from '../../persist'; import blockIcon from '../../icons/cube.svg'; import finalizedIcon from '../../icons/cube-alt.svg'; @@ -26,6 +26,7 @@ export namespace Chain { connection: Promise; settings: PersistentObject; pins: PersistentSet; + sortBy: Persistent>; } export interface State { @@ -91,7 +92,7 @@ export class Chain extends React.Component { return ; } - const { appState, connection, pins } = this.props; + const { appState, connection, pins, sortBy } = this.props; if (display === 'consensus') { return ; @@ -99,7 +100,7 @@ export class Chain extends React.Component { return ( display === 'list' - ? + ? : ); } diff --git a/packages/frontend/src/components/List/Column.tsx b/packages/frontend/src/components/List/Column.tsx new file mode 100644 index 0000000..fd2bcab --- /dev/null +++ b/packages/frontend/src/components/List/Column.tsx @@ -0,0 +1,351 @@ +import * as React from 'react'; +import { Types, Maybe, timestamp } from '@dotstats/common'; +import { State, Node } from '../../state'; +import { Truncate } from './'; +import { Ago, Icon, Tooltip, Sparkline, PolkadotIcon } from '../'; +import { formatNumber, getHashData, milliOrSecond, secondsWithPrecision } from '../../utils'; + +export interface Column { + label: string; + icon: string; + width?: number; + setting?: keyof State.Settings; + sortBy?: (node: Node) => any; + render: (node: Node) => React.ReactElement | string; +} + +import nodeIcon from '../../icons/server.svg'; +import nodeLocationIcon from '../../icons/location.svg'; +import nodeValidatorIcon from '../../icons/shield.svg'; +import nodeTypeIcon from '../../icons/terminal.svg'; +import networkIdIcon from '../../icons/fingerprint.svg'; +import peersIcon from '../../icons/broadcast.svg'; +import transactionsIcon from '../../icons/inbox.svg'; +import blockIcon from '../../icons/cube.svg'; +import finalizedIcon from '../../icons/cube-alt.svg'; +import blockHashIcon from '../../icons/file-binary.svg'; +import blockTimeIcon from '../../icons/history.svg'; +import propagationTimeIcon from '../../icons/dashboard.svg'; +import lastTimeIcon from '../../icons/watch.svg'; +import cpuIcon from '../../icons/microchip-solid.svg'; +import memoryIcon from '../../icons/memory-solid.svg'; +import uploadIcon from '../../icons/cloud-upload.svg'; +import downloadIcon from '../../icons/cloud-download.svg'; +import networkIcon from '../../icons/network.svg'; +import uptimeIcon from '../../icons/pulse.svg'; +import externalLinkIcon from '../../icons/link-external.svg'; + +import parityPolkadotIcon from '../../icons/dot.svg'; +import paritySubstrateIcon from '../../icons/substrate.svg'; +import polkadotJsIcon from '../../icons/polkadot-js.svg'; +import airalabRobonomicsIcon from '../../icons/robonomics.svg'; +import chainXIcon from '../../icons/chainx.svg'; +import edgewareIcon from '../../icons/edgeware.svg'; +import joystreamIcon from '../../icons/joystream.svg'; +import ladderIcon from '../../icons/laddernetwork.svg'; +import cennznetIcon from '../../icons/cennznet.svg'; +import darwiniaIcon from '../../icons/darwinia.svg'; +import turingIcon from '../../icons/turingnetwork.svg'; +import dothereumIcon from '../../icons/dothereum.svg'; +import katalchainIcon from '../../icons/katalchain.svg'; +import unknownImplementationIcon from '../../icons/question-solid.svg'; + +const ICONS = { + 'parity-polkadot': parityPolkadotIcon, + 'polkadot-js': polkadotJsIcon, + 'robonomics-node': airalabRobonomicsIcon, + 'substrate-node': paritySubstrateIcon, + 'edgeware-node': edgewareIcon, + 'joystream-node': joystreamIcon, + 'ChainX': chainXIcon, + 'ladder-node': ladderIcon, + 'cennznet-node': cennznetIcon, + 'Darwinia': darwiniaIcon, + 'Darwinia Testnet': darwiniaIcon, + 'turing-node': turingIcon, + 'dothereum': dothereumIcon, + 'katalchain': katalchainIcon, +}; + +export namespace Column { + export const NAME: Column = { + label: 'Node', + icon: nodeIcon, + sortBy: ({ sortableName }) => sortableName, + render: ({ name }) => + }; + + export const VALIDATOR: Column = { + label: 'Validator', + icon: nodeValidatorIcon, + width: 16, + setting: 'validator', + sortBy: ({ validator }) => validator || '', + render: ({ validator }) => { + return validator ? : '-'; + } + }; + + export const LOCATION: Column = { + label: 'Location', + icon: nodeLocationIcon, + width: 140, + setting: 'location', + sortBy: ({ city }) => city || '', + render: ({ city }) => city ? : '-' + }; + + export const IMPLEMENTATION: Column = { + label: 'Implementation', + icon: nodeTypeIcon, + width: 90, + setting: 'implementation', + sortBy: ({ sortableVersion }) => sortableVersion, + render: ({ implementation, version }) => { + const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?']; + const implIcon = ICONS[implementation] || unknownImplementationIcon; + + return ( + + {semver} + + ); + } + }; + + export const NETWORK_ID: Column = { + label: 'Network ID', + icon: networkIdIcon, + width: 90, + setting: 'networkId', + sortBy: ({ networkId }) => networkId || '', + render: ({ networkId }) => networkId ? : '-' + }; + + export const PEERS: Column = { + label: 'Peer Count', + icon: peersIcon, + width: 26, + setting: 'peers', + sortBy: ({ peers }) => peers, + render: ({ peers }) => `${peers}` + }; + + export const TXS: Column = { + label: 'Transactions in Queue', + icon: transactionsIcon, + width: 26, + setting: 'txs', + sortBy: ({ txs }) => txs, + render: ({ txs }) => `${txs}` + }; + + export const CPU: Column = { + label: '% CPU Use', + icon: cpuIcon, + width: 40, + setting: 'cpu', + sortBy: ({ cpu }) => cpu.length < 3 ? 0 : cpu[cpu.length - 1], + render: ({ cpu, chartstamps }) => { + if (cpu.length < 3) { + return '-'; + } + + return ( + + ); + } + }; + + export const MEM: Column = { + label: 'Memory Use', + icon: memoryIcon, + width: 40, + setting: 'mem', + sortBy: ({ mem }) => mem.length < 3 ? 0 : mem[mem.length - 1], + render: ({ mem, chartstamps }) => { + if (mem.length < 3) { + return '-'; + } + + return ( + + ); + } + }; + + export const UPLOAD: Column = { + label: 'Upload Bandwidth', + icon: uploadIcon, + width: 40, + setting: 'upload', + sortBy: ({ upload }) => upload.length < 3 ? 0 : upload[upload.length - 1], + render: ({ upload, chartstamps }) => { + if (upload.length < 3) { + return '-'; + } + + return ( + + ); + } + }; + + export const DOWNLOAD: Column = { + label: 'Download Bandwidth', + icon: downloadIcon, + width: 40, + setting: 'download', + sortBy: ({ download }) => download.length < 3 ? 0 : download[download.length - 1], + render: ({ download, chartstamps }) => { + if (download.length < 3) { + return '-'; + } + + return ( + + ); + } + }; + + export const BLOCK_NUMBER: Column = { + label: 'Block', + icon: blockIcon, + width: 88, + setting: 'blocknumber', + sortBy: ({ height }) => height || 0, + render: ({ height }) => `#${formatNumber(height)}` + }; + + export const BLOCK_HASH: Column = { + label: 'Block Hash', + icon: blockHashIcon, + width: 154, + setting: 'blockhash', + sortBy: ({ hash }) => hash || '', + render: ({ hash }) => + }; + + export const FINALIZED: Column = { + label: 'Finalized Block', + icon: finalizedIcon, + width: 88, + setting: 'finalized', + sortBy: ({ finalized }) => finalized || 0, + render: ({ finalized }) => `#${formatNumber(finalized)}` + }; + + export const FINALIZED_HASH: Column = { + label: 'Finalized Block Hash', + icon: blockHashIcon, + width: 154, + setting: 'finalizedhash', + sortBy: ({ finalizedHash }) => finalizedHash || '', + render: ({ finalizedHash }) => + }; + + export const BLOCK_TIME: Column = { + label: 'Block Time', + icon: blockTimeIcon, + width: 80, + setting: 'blocktime', + sortBy: ({ blockTime }) => blockTime == null ? Infinity : blockTime, + render: ({ blockTime }) => `${secondsWithPrecision(blockTime/1000)}` + }; + + export const BLOCK_PROPAGATION: Column = { + label: 'Block Propagation Time', + icon: propagationTimeIcon, + width: 58, + setting: 'blockpropagation', + sortBy: ({ propagationTime }) => propagationTime == null ? Infinity : propagationTime, + render: ({ propagationTime }) => propagationTime == null ? '∞' : milliOrSecond(propagationTime) + }; + + export const BLOCK_LAST_TIME: Column = { + label: 'Last Block Time', + icon: lastTimeIcon, + width: 100, + setting: 'blocklasttime', + sortBy: ({ blockTimestamp }) => blockTimestamp || 0, + render: ({ blockTimestamp }) => + }; + + export const UPTIME: Column = { + label: 'Node Uptime', + icon: uptimeIcon, + width: 58, + setting: 'uptime', + sortBy: ({ connectedAt }) => connectedAt || 0, + render: ({ connectedAt }) => + }; + + export const NETWORK_STATE: Column = { + label: 'NetworkState', + icon: networkIcon, + width: 16, + setting: 'networkstate', + render: ({ id }) => { + const chainLabel = getHashData().chain; + + if (!chainLabel) { + return '-'; + } + + const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`; + return ; + }, + }; +}; + +const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; +const BANDWIDTH_SCALE = 1024 * 1024; +const MEMORY_SCALE = 2 * 1024 * 1024; +const URI_BASE = window.location.protocol === 'https:' + ? `/network_state/` + : `http://${window.location.hostname}:8000/network_state/`; + +function formatStamp(stamp: Types.Timestamp): string { + const passed = (timestamp() - stamp) / 1000 | 0; + + const hours = passed / 3600 | 0; + const minutes = (passed % 3600) / 60 | 0; + const seconds = (passed % 60) | 0; + + return hours ? `${hours}h ago` + : minutes ? `${minutes}m ago` + : `${seconds}s ago`; +} + +function formatMemory(kbs: number, stamp: Maybe): string { + const ago = stamp ? ` (${formatStamp(stamp)})` : ''; + const mbs = kbs / 1024 | 0; + + if (mbs >= 1000) { + return `${(mbs / 1024).toFixed(1)} GB${ago}`; + } else { + return `${mbs} MB${ago}`; + } +} + +function formatBandwidth(bps: number, stamp: Maybe): string { + const ago = stamp ? ` (${formatStamp(stamp)})` : ''; + + if (bps >= 1024 * 1024) { + return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`; + } else if (bps >= 1000) { + return `${(bps / 1024).toFixed(1)} kB/s${ago}`; + } else { + return `${bps | 0} B/s${ago}`; + } +} + +function formatCPU(cpu: number, stamp: Maybe): string { + const ago = stamp ? ` (${formatStamp(stamp)})` : ''; + const fractionDigits = cpu > 100 ? 0 + : cpu > 10 ? 1 + : cpu > 1 ? 2 + : 3; + + return `${cpu.toFixed(fractionDigits)}%${ago}`; +} diff --git a/packages/frontend/src/components/List/HeaderCell.tsx b/packages/frontend/src/components/List/HeaderCell.tsx new file mode 100644 index 0000000..d9218f6 --- /dev/null +++ b/packages/frontend/src/components/List/HeaderCell.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Maybe } from '@dotstats/common'; +import { Column } from './'; +import { Icon, Tooltip } from '../'; +import { Persistent } from '../../persist'; + +import sortAscIcon from '../../icons/triangle-up.svg'; +import sortDescIcon from '../../icons/triangle-down.svg'; + +export namespace HeaderCell { + export interface Props { + column: Column; + index: number; + last: number; + sortBy: Persistent>; + } +} + +export class HeaderCell extends React.Component { + public render() { + const { column, index, last } = this.props; + const { icon, width, label } = column; + const position = index === 0 ? 'left' + : index === last ? 'right' + : 'center'; + + const sortBy = this.props.sortBy.get(); + const className = column.sortBy == null ? '' : sortBy === index || sortBy === ~index ? 'HeaderCell-sorted' : 'HeaderCell-sortable'; + const i = sortBy === index ? sortAscIcon : sortBy === ~index ? sortDescIcon : icon; + + return ( + + + + ) + } + + 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); + } + } +} diff --git a/packages/frontend/src/components/List/List.tsx b/packages/frontend/src/components/List/List.tsx index 8181f5a..b45b97e 100644 --- a/packages/frontend/src/components/List/List.tsx +++ b/packages/frontend/src/components/List/List.tsx @@ -3,7 +3,7 @@ import { Types, Maybe } from '@dotstats/common'; import { Filter } from '../'; import { State as AppState, Node } from '../../state'; import { Row } from './'; -import { PersistentSet } from '../../persist'; +import { Persistent, PersistentSet } from '../../persist'; import { viewport } from '../../utils' const HEADER = 148; @@ -17,6 +17,7 @@ export namespace List { export interface Props { appState: Readonly; pins: PersistentSet; + sortBy: Persistent>; } export interface State { @@ -51,10 +52,9 @@ export class List extends React.Component { } public render() { - const { settings } = this.props.appState; - const { pins } = this.props; + const { selectedColumns } = this.props.appState; + const { pins, sortBy } = this.props; const { filter } = this.state; - const columns = Row.columns.filter(({ setting }) => setting == null || settings[setting]); let nodes = this.props.appState.nodes.sorted(); @@ -82,10 +82,10 @@ export class List extends React.Component {
- + { - nodes.map((node) => ) + nodes.map((node) => ) }
diff --git a/packages/frontend/src/components/List/Row.css b/packages/frontend/src/components/List/Row.css index fee1a04..ca7d78e 100644 --- a/packages/frontend/src/components/List/Row.css +++ b/packages/frontend/src/components/List/Row.css @@ -26,6 +26,16 @@ height: 23px; } +.Row-Header th.HeaderCell-sortable { + cursor: pointer; +} + +.Row-Header th.HeaderCell-sorted { + cursor: pointer; + background: #E6007A; + color: #fff; +} + .Row .Row-truncate { position: absolute; left: 0; diff --git a/packages/frontend/src/components/List/Row.tsx b/packages/frontend/src/components/List/Row.tsx index 2b3e62a..8573b69 100644 --- a/packages/frontend/src/components/List/Row.tsx +++ b/packages/frontend/src/components/List/Row.tsx @@ -1,69 +1,11 @@ import * as React from 'react'; -import { Types, Maybe, timestamp } from '@dotstats/common'; -import { formatNumber, getHashData, milliOrSecond, secondsWithPrecision } from '../../utils'; -import { State as AppState, Node } from '../../state'; -import { PersistentSet } from '../../persist'; -import { Truncate } from './'; -import { Ago, Icon, Tooltip, Sparkline, PolkadotIcon } from '../'; - -import nodeIcon from '../../icons/server.svg'; -import nodeLocationIcon from '../../icons/location.svg'; -import nodeValidatorIcon from '../../icons/shield.svg'; -import nodeTypeIcon from '../../icons/terminal.svg'; -import networkIdIcon from '../../icons/fingerprint.svg'; -import peersIcon from '../../icons/broadcast.svg'; -import transactionsIcon from '../../icons/inbox.svg'; -import blockIcon from '../../icons/cube.svg'; -import finalizedIcon from '../../icons/cube-alt.svg'; -import blockHashIcon from '../../icons/file-binary.svg'; -import blockTimeIcon from '../../icons/history.svg'; -import propagationTimeIcon from '../../icons/dashboard.svg'; -import lastTimeIcon from '../../icons/watch.svg'; -import cpuIcon from '../../icons/microchip-solid.svg'; -import memoryIcon from '../../icons/memory-solid.svg'; -import uploadIcon from '../../icons/cloud-upload.svg'; -import downloadIcon from '../../icons/cloud-download.svg'; -import networkIcon from '../../icons/network.svg'; -import uptimeIcon from '../../icons/pulse.svg'; -import externalLinkIcon from '../../icons/link-external.svg'; - -import parityPolkadotIcon from '../../icons/dot.svg'; -import paritySubstrateIcon from '../../icons/substrate.svg'; -import polkadotJsIcon from '../../icons/polkadot-js.svg'; -import airalabRobonomicsIcon from '../../icons/robonomics.svg'; -import chainXIcon from '../../icons/chainx.svg'; -import edgewareIcon from '../../icons/edgeware.svg'; -import joystreamIcon from '../../icons/joystream.svg'; -import ladderIcon from '../../icons/laddernetwork.svg'; -import cennznetIcon from '../../icons/cennznet.svg'; -import darwiniaIcon from '../../icons/darwinia.svg'; -import turingIcon from '../../icons/turingnetwork.svg'; -import dothereumIcon from '../../icons/dothereum.svg'; -import katalchainIcon from '../../icons/katalchain.svg'; -import unknownImplementationIcon from '../../icons/question-solid.svg'; +import { Types, Maybe } from '@dotstats/common'; +import { Node } from '../../state'; +import { Persistent, PersistentSet } from '../../persist'; +import { HeaderCell, Column } from './'; import './Row.css'; -const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; -const BANDWIDTH_SCALE = 1024 * 1024; -const MEMORY_SCALE = 2 * 1024 * 1024; -const ICONS = { - 'parity-polkadot': parityPolkadotIcon, - 'polkadot-js': polkadotJsIcon, - 'robonomics-node': airalabRobonomicsIcon, - 'substrate-node': paritySubstrateIcon, - 'edgeware-node': edgewareIcon, - 'joystream-node': joystreamIcon, - 'ChainX': chainXIcon, - 'ladder-node': ladderIcon, - 'cennznet-node': cennznetIcon, - 'Darwinia': darwiniaIcon, - 'Darwinia Testnet': darwiniaIcon, - 'turing-node': turingIcon, - 'dothereum': dothereumIcon, - 'katalchain': katalchainIcon, -}; - export namespace Row { export interface Props { node: Node; @@ -78,278 +20,44 @@ export namespace Row { interface HeaderProps { columns: Column[]; + sortBy: Persistent>; } -interface Column { - label: string; - icon: string; - width?: number; - setting?: keyof AppState.Settings; - render: (node: Node) => React.ReactElement | string; -} - -function formatStamp(stamp: Types.Timestamp): string { - const passed = (timestamp() - stamp) / 1000 | 0; - - const hours = passed / 3600 | 0; - const minutes = (passed % 3600) / 60 | 0; - const seconds = (passed % 60) | 0; - - return hours ? `${hours}h ago` - : minutes ? `${minutes}m ago` - : `${seconds}s ago`; -} - -function formatMemory(kbs: number, stamp: Maybe): string { - const ago = stamp ? ` (${formatStamp(stamp)})` : ''; - const mbs = kbs / 1024 | 0; - - if (mbs >= 1000) { - return `${(mbs / 1024).toFixed(1)} GB${ago}`; - } else { - return `${mbs} MB${ago}`; - } -} - -function formatBandwidth(bps: number, stamp: Maybe): string { - const ago = stamp ? ` (${formatStamp(stamp)})` : ''; - - if (bps >= 1024 * 1024) { - return `${(bps / (1024 * 1024)).toFixed(1)} MB/s${ago}`; - } else if (bps >= 1000) { - return `${(bps / 1024).toFixed(1)} kB/s${ago}`; - } else { - return `${bps | 0} B/s${ago}`; - } -} - -function formatCPU(cpu: number, stamp: Maybe): string { - const ago = stamp ? ` (${formatStamp(stamp)})` : ''; - const fractionDigits = cpu > 100 ? 0 - : cpu > 10 ? 1 - : cpu > 1 ? 2 - : 3; - - return `${cpu.toFixed(fractionDigits)}%${ago}`; -} - -const URI_BASE = window.location.protocol === 'https:' - ? `/network_state/` - : `http://${window.location.hostname}:8000/network_state/`; - export class Row extends React.Component { public static readonly columns: Column[] = [ - { - label: 'Node', - icon: nodeIcon, - render: ({ name }) => - }, - { - label: 'Validator', - icon: nodeValidatorIcon, - width: 16, - setting: 'validator', - render: ({ validator }) => { - return validator ? : '-'; - } - }, - { - label: 'Location', - icon: nodeLocationIcon, - width: 140, - setting: 'location', - render: ({ city }) => city ? : '-' - }, - { - label: 'Implementation', - icon: nodeTypeIcon, - width: 90, - setting: 'implementation', - render: ({ implementation, version }) => { - const [semver] = version.match(SEMVER_PATTERN) || ['?.?.?']; - const implIcon = ICONS[implementation] || unknownImplementationIcon; - - return ( - - {semver} - - ); - } - }, - { - label: 'Network ID', - icon: networkIdIcon, - width: 90, - setting: 'networkId', - render: ({ networkId }) => networkId ? : '-' - }, - { - label: 'Peer Count', - icon: peersIcon, - width: 26, - setting: 'peers', - render: ({ peers }) => `${peers}` - }, - { - label: 'Transactions in Queue', - icon: transactionsIcon, - width: 26, - setting: 'txs', - render: ({ txs }) => `${txs}` - }, - { - label: '% CPU Use', - icon: cpuIcon, - width: 40, - setting: 'cpu', - render: ({ cpu, chartstamps }) => { - if (cpu.length < 3) { - return '-'; - } - - return ( - - ); - } - }, - { - label: 'Memory Use', - icon: memoryIcon, - width: 40, - setting: 'mem', - render: ({ mem, chartstamps }) => { - if (mem.length < 3) { - return '-'; - } - - return ( - - ); - } - }, - { - label: 'Upload Bandwidth', - icon: uploadIcon, - width: 40, - setting: 'upload', - render: ({ upload, chartstamps }) => { - if (upload.length < 3) { - return '-'; - } - - return ( - - ); - } - }, - { - label: 'Download Bandwidth', - icon: downloadIcon, - width: 40, - setting: 'download', - render: ({ download, chartstamps }) => { - if (download.length < 3) { - return '-'; - } - - return ( - - ); - } - }, - { - label: 'Block', - icon: blockIcon, - width: 88, - setting: 'blocknumber', - render: ({ height }) => `#${formatNumber(height)}` - }, - { - label: 'Block Hash', - icon: blockHashIcon, - width: 154, - setting: 'blockhash', - render: ({ hash }) => - }, - { - label: 'Finalized Block', - icon: finalizedIcon, - width: 88, - setting: 'finalized', - render: ({ finalized }) => `#${formatNumber(finalized)}` - }, - { - label: 'Finalized Block Hash', - icon: blockHashIcon, - width: 154, - setting: 'finalizedhash', - render: ({ finalizedHash }) => - }, - { - label: 'Block Time', - icon: blockTimeIcon, - width: 80, - setting: 'blocktime', - render: ({ blockTime }) => `${secondsWithPrecision(blockTime/1000)}` - }, - { - label: 'Block Propagation Time', - icon: propagationTimeIcon, - width: 58, - setting: 'blockpropagation', - render: ({ propagationTime }) => propagationTime == null ? '∞' : milliOrSecond(propagationTime) - }, - { - label: 'Last Block Time', - icon: lastTimeIcon, - width: 100, - setting: 'blocklasttime', - render: ({ blockTimestamp }) => - }, - { - label: 'Node Uptime', - icon: uptimeIcon, - width: 58, - setting: 'uptime', - render: ({ connectedAt }) => - }, - { - label: 'NetworkState', - icon: networkIcon, - width: 16, - setting: 'networkstate', - render: ({ id }) => { - const chainLabel = getHashData().chain; - - if (!chainLabel) { - return '-'; - } - - const uri = `${URI_BASE}${encodeURIComponent(chainLabel)}/${id}/`; - return ; - }, - }, + Column.NAME, + Column.VALIDATOR, + Column.LOCATION, + Column.IMPLEMENTATION, + Column.NETWORK_ID, + Column.PEERS, + Column.TXS, + Column.CPU, + Column.MEM, + Column.UPLOAD, + Column.DOWNLOAD, + Column.BLOCK_NUMBER, + Column.BLOCK_HASH, + Column.FINALIZED, + Column.FINALIZED_HASH, + Column.BLOCK_TIME, + Column.BLOCK_PROPAGATION, + Column.BLOCK_LAST_TIME, + Column.UPTIME, + Column.NETWORK_STATE, ]; public static Header = (props: HeaderProps) => { - const { columns } = props; + const { columns, sortBy } = props; const last = columns.length - 1; return ( { - columns.map(({ icon, width, label }, index) => { - const position = index === 0 ? 'left' - : index === last ? 'right' - : 'center'; - - return ( - - - - ) - }) + columns.map((col, index) => ( + + )) } diff --git a/packages/frontend/src/components/List/index.ts b/packages/frontend/src/components/List/index.ts index 82a4d89..5c0aa0c 100644 --- a/packages/frontend/src/components/List/index.ts +++ b/packages/frontend/src/components/List/index.ts @@ -1,3 +1,5 @@ +export * from './Column'; export * from './List'; export * from './Truncate'; export * from './Row'; +export * from './HeaderCell'; diff --git a/packages/frontend/src/state.ts b/packages/frontend/src/state.ts index 4562a3f..d73de27 100644 --- a/packages/frontend/src/state.ts +++ b/packages/frontend/src/state.ts @@ -1,4 +1,5 @@ import { Types, Maybe, SortedCollection } from '@dotstats/common'; +import { Column } from './components/List'; export const PINNED_CHAIN = 'Kusama CC2'; @@ -31,6 +32,9 @@ export class Node { public readonly networkId: Maybe; public readonly connectedAt: Types.Timestamp; + public readonly sortableName: string; + public readonly sortableVersion: number; + public stale: boolean; public pinned: boolean; public peers: Types.PeerCount; @@ -79,6 +83,11 @@ export class Node { this.networkId = networkId; this.connectedAt = connectedAt; + const [major = 0, minor = 0, patch = 0] = (version || '0.0.0').split('.').map((n) => parseInt(n, 10) | 0); + + this.sortableName = name.toLocaleLowerCase(); + this.sortableVersion = (major * 1000 + minor * 100 + patch) | 0; + this.updateStats(nodeStats); this.updateHardware(nodeHardware); this.updateBlock(blockDetails); @@ -203,6 +212,11 @@ export namespace State { uptime: boolean; networkstate: boolean; } + + export interface SortBy { + column: string; + reverse: boolean; + } } export interface State { @@ -223,6 +237,8 @@ export interface State { nodes: SortedCollection; settings: Readonly; pins: Readonly>; + sortBy: Readonly>; + selectedColumns: Column[]; } export type Update = (changes: Pick | null) => Readonly;