diff --git a/packages/common/src/feed.ts b/packages/common/src/feed.ts index d6b6bf1..93f586c 100644 --- a/packages/common/src/feed.ts +++ b/packages/common/src/feed.ts @@ -1,4 +1,5 @@ import { Opaque, Maybe } from './helpers'; +import { stringify, parse, Stringified } from './stringify'; import { FeedVersion, Address, @@ -123,11 +124,11 @@ export type Message = | Variants.PongMessage; /** - * Opaque data type to be sent to the feed. Passing through - * strings means we can only serialize once, no matter how - * many feed clients are listening in. + * Data type to be sent to the feed. Passing through strings means we can only serialize once, + * no matter how many feed clients are listening in. */ -export type Data = Opaque; +export interface SquashedMessages extends Array {}; +export type Data = Stringified; /** * Serialize an array of `Message`s to a single JSON string. @@ -137,7 +138,7 @@ export type Data = Opaque; * Action `string`s are converted to opcodes using the `actionToCode` mapping. */ export function serialize(messages: Array): Data { - const squashed = new Array(messages.length * 2); + const squashed: SquashedMessages = new Array(messages.length * 2); let index = 0; messages.forEach((message) => { @@ -147,20 +148,20 @@ export function serialize(messages: Array): Data { squashed[index++] = payload; }) - return JSON.stringify(squashed) as Data; + return stringify(squashed); } /** * Deserialize data to an array of `Message`s. */ export function deserialize(data: Data): Array { - const json: Array = JSON.parse(data); + const json = parse(data); if (!Array.isArray(json) || json.length === 0 || json.length % 2 !== 0) { throw new Error('Invalid FeedMessage.Data'); } - const messages: Array = new Array(json.length / 2); + const messages = new Array(json.length / 2); for (const index of messages.keys()) { const [ action, payload ] = json.slice(index * 2); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 45c491d..4aa3819 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,6 +1,7 @@ export * from './helpers'; export * from './id'; export * from './block'; +export * from './stringify'; import * as Types from './types'; import * as FeedMessage from './feed'; @@ -8,4 +9,4 @@ import * as FeedMessage from './feed'; export { Types, FeedMessage }; // Increment this if breaking changes were made to types in `feed.ts` -export const VERSION: Types.FeedVersion = 11 as Types.FeedVersion; +export const VERSION: Types.FeedVersion = 12 as Types.FeedVersion; diff --git a/packages/common/src/stringify.ts b/packages/common/src/stringify.ts new file mode 100644 index 0000000..0ba7818 --- /dev/null +++ b/packages/common/src/stringify.ts @@ -0,0 +1,4 @@ +export abstract class Stringified { public __PHANTOM__: T }; + +export const parse = JSON.parse as any as (val: Stringified) => T; +export const stringify = JSON.stringify as any as (val: T) => Stringified; diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 76d4e8f..5bff38f 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -2,27 +2,52 @@ import * as React from 'react'; import { Types } from '@dotstats/common'; import { Chains, Chain, Ago, OfflineIndicator } from './components'; import { Connection } from './Connection'; +import { Persistent } from './Persistent'; import { State } from './state'; import './App.css'; export default class App extends React.Component<{}, State> { - public state: State = { - status: 'offline', - best: 0 as Types.BlockNumber, - blockTimestamp: 0 as Types.Timestamp, - blockAverage: null, - timeDiff: 0 as Types.Milliseconds, - subscribed: null, - chains: new Map(), - nodes: new Map() - }; - + public state: State; private connection: Promise; + private settings: Persistent; + private setSettings: Persistent['set']; constructor(props: {}) { super(props); + this.settings = new Persistent( + 'settings', + { + validator: true, + implementation: true, + peers: true, + txs: true, + cpu: true, + mem: true, + blocknumber: true, + blockhash: true, + blocktime: true, + blockpropagation: true, + blocklasttime: false + }, + (settings) => this.setState({ settings }) + ); + + this.setSettings = this.settings.set.bind(this.settings); + + this.state = { + status: 'offline', + best: 0 as Types.BlockNumber, + blockTimestamp: 0 as Types.Timestamp, + blockAverage: null, + timeDiff: 0 as Types.Milliseconds, + subscribed: null, + chains: new Map(), + nodes: new Map(), + settings: this.settings.get(), + }; + this.connection = Connection.create((changes) => { if (changes) { this.setState(changes); @@ -50,7 +75,7 @@ export default class App extends React.Component<{}, State> {
- +
); } diff --git a/packages/frontend/src/Persistent.ts b/packages/frontend/src/Persistent.ts new file mode 100644 index 0000000..e0d1dd3 --- /dev/null +++ b/packages/frontend/src/Persistent.ts @@ -0,0 +1,38 @@ +import { Maybe, Stringified, stringify, parse } from '@dotstats/common'; + +export class Persistent { + private readonly onChange: (value: Data) => void; + private readonly key: string; + private value: Data; + + constructor(key: string, initial: Data, onChange: (value: Data) => void) { + this.key = key; + this.onChange = onChange; + + const stored = window.localStorage.getItem(key) as Maybe>; + + if (stored) { + this.value = parse(stored); + } else { + this.value = initial; + } + + window.addEventListener('storage', (event) => { + if (event.key === this.key) { + this.value = parse(event.newValue as any as Stringified); + + this.onChange(this.value); + } + }); + } + + public get(): Data { + return this.value; + } + + public set(changes: Pick | Data) { + this.value = Object.assign({}, this.value, changes); + window.localStorage.setItem(this.key, stringify(this.value) as any as string); + this.onChange(this.value); + } +} diff --git a/packages/frontend/src/components/Chain.css b/packages/frontend/src/components/Chain/Chain.css similarity index 58% rename from packages/frontend/src/components/Chain.css rename to packages/frontend/src/components/Chain/Chain.css index abc5fa8..f36b63d 100644 --- a/packages/frontend/src/components/Chain.css +++ b/packages/frontend/src/components/Chain/Chain.css @@ -8,11 +8,23 @@ position: relative; } -.Chain-map-toggle .Icon { + +.Chain-tabs { position: absolute; - right: 20px; + right: 10px; bottom: 0; - font-size: 32px; + height: 40px; + width: 200px; + text-align: right; +} + +.Chain-tab-unit { + display: inline-block; +} + +.Chain-tab-unit .Icon { + margin-right: 10px; + font-size: 26px; padding: 6px; background: #222; color: #aaa; @@ -21,7 +33,7 @@ border-radius: 4px 4px 0 0; } -.Chain-map-toggle-on .Icon { +.Chain-tab-unit-on .Icon { color: #fff; } @@ -44,7 +56,7 @@ .Chain-map { min-width: 1350px; - background: url('../assets/world-map.svg') no-repeat; + background: url('../../assets/world-map.svg') no-repeat; background-size: contain; background-position: center; position: absolute; @@ -71,3 +83,35 @@ text-align: left; padding: 0.5em 1em; } + +.Chain-settings { + text-align: center; +} + +.Chain-settings-category { + text-align: left; + width: 500px; + margin: 0 auto; + padding: 2em 0; +} + +.Chain-settings-category h2 { + padding: 0; + margin: 0 0 0.5em 0; + font-size: 20px; + font-weight: 100; +} + +.Chain-settings-category p { + padding: 0; + margin: 0 0 0.1em 0; + cursor: pointer; +} + +.Chain-settings-category .Icon { + margin-right: 10px; +} + +.Chain-settings-disabled { + color: #666; +} diff --git a/packages/frontend/src/components/Chain.tsx b/packages/frontend/src/components/Chain/Chain.tsx similarity index 61% rename from packages/frontend/src/components/Chain.tsx rename to packages/frontend/src/components/Chain/Chain.tsx index fc9c898..7645090 100644 --- a/packages/frontend/src/components/Chain.tsx +++ b/packages/frontend/src/components/Chain/Chain.tsx @@ -1,13 +1,17 @@ import * as React from 'react'; -import { State as AppState } from '../state'; -import { formatNumber, secondsWithPrecision, viewport } from '../utils'; -import { Tile, Icon, Node, Ago } from './'; +import { State as AppState } from '../../state'; +import { formatNumber, secondsWithPrecision, viewport } from '../../utils'; +import { Tab } from './'; +import { Tile, Icon, Node, Ago } from '../'; import { Types } from '@dotstats/common'; +import { Persistent } from '../../Persistent'; -import blockIcon from '../icons/package.svg'; -import blockTimeIcon from '../icons/history.svg'; -import lastTimeIcon from '../icons/watch.svg'; -import worldIcon from '../icons/globe.svg'; +import blockIcon from '../../icons/package.svg'; +import blockTimeIcon from '../../icons/history.svg'; +import lastTimeIcon from '../../icons/watch.svg'; +import listIcon from '../../icons/list-unordered.svg'; +import worldIcon from '../../icons/globe.svg'; +import settingsIcon from '../../icons/settings.svg'; const MAP_RATIO = 800 / 350; const MAP_HEIGHT_ADJUST = 400 / 350; @@ -16,12 +20,15 @@ const HEADER = 148; import './Chain.css'; export namespace Chain { + export type Display = 'list' | 'map' | 'settings'; + export interface Props { appState: Readonly; + setSettings: Persistent['set']; } export interface State { - display: 'map' | 'table'; + display: Display; map: { width: number; height: number; @@ -48,8 +55,19 @@ export class Chain extends React.Component { constructor(props: Chain.Props) { super(props); + let display: Chain.Display = 'list'; + + switch (window.location.hash) { + case '#map': + display = 'map'; + break; + case '#settings': + display = 'settings'; + break; + } + this.state = { - display: window.location.hash === '#map' ? 'map' : 'table', + display, map: { width: 0, height: 0, @@ -71,13 +89,7 @@ export class Chain extends React.Component { public render() { const { best, blockTimestamp, blockAverage } = this.props.appState; - const { display } = this.state; - - const toggleClass = ['Chain-map-toggle']; - - if (display === 'map') { - toggleClass.push('Chain-map-toggle-on'); - } + const currentTab = this.state.display; return (
@@ -85,16 +97,20 @@ export class Chain extends React.Component { #{formatNumber(best)} { blockAverage == null ? '-' : secondsWithPrecision(blockAverage / 1000) } -
- +
+ + +
{ - display === 'table' - ? this.renderTable() - : this.renderMap() + currentTab === 'list' + ? this.renderList() + : currentTab === 'map' + ? this.renderMap() + : this.renderSettings() }
@@ -102,14 +118,26 @@ export class Chain extends React.Component { ); } - private toggleMap = () => { - if (this.state.display === 'map') { - this.setState({ display: 'table' }); - window.location.hash = ''; - } else { - this.setState({ display: 'map' }); - window.location.hash = '#map'; - } + private setDisplay = (display: Chain.Display) => { + this.setState({ display }); + }; + + private renderList() { + const { settings } = this.props.appState; + + return ( + + + + { + this + .nodes() + .sort(sortNodes) + .map((node) => ) + } + +
+ ); } private renderMap() { @@ -135,19 +163,35 @@ export class Chain extends React.Component { ); } - private renderTable() { + private renderSettings() { + const { settings } = this.props.appState; + return ( - - - - { - this - .nodes() - .sort(sortNodes) - .map((node) => ) - } - -
+
+
+

Visible Columns

+ { + Node.Row.columns + .map(({ label, icon, setting }, index) => { + if (!setting) { + return null; + } + + const className = settings[setting] ? '' : 'Chain-settings-disabled'; + + const changeSetting = () => { + const change = {}; + + change[setting] = !settings[setting]; + + this.props.setSettings(change); + } + + return

{label}

; + }) + } +
+
); } diff --git a/packages/frontend/src/components/Chain/Tab.tsx b/packages/frontend/src/components/Chain/Tab.tsx new file mode 100644 index 0000000..0a8fada --- /dev/null +++ b/packages/frontend/src/components/Chain/Tab.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Chain } from './'; +import { Icon } from '../'; + +export namespace Tab { + export interface Props { + label: string; + icon: string; + display: Chain.Display; + current: string; + hash: string; + setDisplay: (display: Chain.Display) => void; + } +} + +export class Tab extends React.Component { + public render() { + const { label, icon, display, current } = this.props; + const highlight = display === current; + const className = highlight ? 'Chain-tab-unit-on Chain-tab-unit' : 'Chain-tab-unit'; + + return ( +
+ +
+ ); + } + + private onClick = () => { + const { hash, display, setDisplay } = this.props; + window.location.hash = hash; + setDisplay(display); + } +} diff --git a/packages/frontend/src/components/Chain/index.ts b/packages/frontend/src/components/Chain/index.ts new file mode 100644 index 0000000..03ecb91 --- /dev/null +++ b/packages/frontend/src/components/Chain/index.ts @@ -0,0 +1,2 @@ +export * from './Chain'; +export * from './Tab'; diff --git a/packages/frontend/src/components/Node/Row.tsx b/packages/frontend/src/components/Node/Row.tsx index c2fec68..8ff18c3 100644 --- a/packages/frontend/src/components/Node/Row.tsx +++ b/packages/frontend/src/components/Node/Row.tsx @@ -3,7 +3,7 @@ import Identicon from 'polkadot-identicon'; import { formatNumber, trimHash, milliOrSecond, secondsWithPrecision } from '../../utils'; import { State as AppState } from '../../state'; import { SEMVER_PATTERN } from './'; -import { /*Ago,*/ Icon } from '../'; +import { Ago, Icon } from '../'; import nodeIcon from '../../icons/server.svg'; import nodeValidatorIcon from '../../icons/shield.svg'; @@ -14,41 +14,161 @@ 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 lastTimeIcon from '../../icons/watch.svg'; import cpuIcon from '../../icons/microchip-solid.svg'; import memoryIcon from '../../icons/memory-solid.svg'; import './Row.css'; -export default class Row extends React.Component { - public static Header = () => { +interface RowProps { + node: AppState.Node; + settings: AppState.Settings; +}; + +interface HeaderProps { + settings: AppState.Settings; +}; + +interface Column { + label: string; + icon: string; + width?: number; + setting?: keyof AppState.Settings; + render: (node: AppState.Node) => React.ReactElement | string; +} + +export default class Row extends React.Component { + public static readonly columns: Column[] = [ + { + label: 'Node', + icon: nodeIcon, + render: ({ nodeDetails }) => nodeDetails[0] + }, + { + label: 'Validator', + icon: nodeValidatorIcon, + width: 26, + setting: 'validator', + render: ({ nodeDetails }) => { + const validator = nodeDetails[3]; + + return validator ? : '-'; + } + }, + { + label: 'Implementation', + icon: nodeTypeIcon, + width: 240, + setting: 'implementation', + render: ({ nodeDetails }) => { + const [, implementation, version] = nodeDetails; + const [semver] = version.match(SEMVER_PATTERN) || [version]; + + return {implementation} v{semver}; + } + }, + { + label: 'Peer Count', + icon: peersIcon, + width: 26, + setting: 'peers', + render: ({ nodeStats }) => `${nodeStats[0]}` + }, + { + label: 'Transactions in Queue', + icon: transactionsIcon, + width: 26, + setting: 'txs', + render: ({ nodeStats }) => `${nodeStats[1]}` + }, + { + label: '% CPU Use', + icon: cpuIcon, + width: 26, + setting: 'cpu', + render: ({ nodeStats }) => { + const cpu = nodeStats[3]; + + return cpu ? `${(cpu * 100).toFixed(1)}%` : '-'; + } + }, + { + label: 'Memory use', + icon: memoryIcon, + width: 26, + setting: 'mem', + render: ({ nodeStats }) => { + const memory = nodeStats[2]; + + return memory ? {memory / 1024 | 0}mb : '-'; + } + }, + { + label: 'Block', + icon: blockIcon, + width: 88, + setting: 'blocknumber', + render: ({ blockDetails }) => `#${formatNumber(blockDetails[0])}` + }, + { + label: 'Block Hash', + icon: blockHashIcon, + width: 154, + setting: 'blockhash', + render: ({ blockDetails }) => { + const hash = blockDetails[1]; + + return {trimHash(hash, 16)}; + } + }, + { + label: 'Block Time', + icon: blockTimeIcon, + width: 80, + setting: 'blocktime', + render: ({ blockDetails }) => `${secondsWithPrecision(blockDetails[2]/1000)}` + }, + { + label: 'Block Propagation Time', + icon: propagationTimeIcon, + width: 58, + setting: 'blockpropagation', + render: ({ blockDetails }) => { + const propagationTime = blockDetails[4]; + + return propagationTime === null ? '∞' : milliOrSecond(propagationTime as number); + } + }, + { + label: 'Last Block Time', + icon: lastTimeIcon, + width: 100, + setting: 'blocklasttime', + render: ({ blockDetails }) => + }, + ]; + + public static Header = (props: HeaderProps) => { + const { settings } = props; + return ( - - - - - - - - - - - - {/* */} + { + Row.columns + .filter(({ setting }) => setting == null || settings[setting]) + .map(({ icon, width, label }, index) => ( + + )) + } ) } public render() { - const { nodeDetails, blockDetails, nodeStats } = this.props; - - const [name, implementation, version, validator] = nodeDetails; - const [height, hash, blockTime, /*blockTimestamp*/, propagationTime] = blockDetails; - const [peers, txcount, memory, cpu] = nodeStats; - const [semver] = version.match(SEMVER_PATTERN) || [version]; + const { node, settings } = this.props; + const propagationTime = node.blockDetails[4]; let className = 'Node-Row'; @@ -58,18 +178,11 @@ export default class Row extends React.Component { return ( - {name} - {validator ? : null} - {implementation} v{semver} - {peers} - {txcount} - {cpu ? `${(cpu * 100).toFixed(1)}%` : '-'} - {memory ? {memory / 1024 | 0}mb : '-'} - #{formatNumber(height)} - {trimHash(hash, 16)} - {secondsWithPrecision(blockTime/1000)} - {propagationTime === null ? '∞' : milliOrSecond(propagationTime as number)} - {/* */} + { + Row.columns + .filter(({ setting }) => setting == null || settings[setting]) + .map(({ render }, index) => {render(node)}) + } ); } diff --git a/packages/frontend/src/state.ts b/packages/frontend/src/state.ts index 34d4994..80c0b9d 100644 --- a/packages/frontend/src/state.ts +++ b/packages/frontend/src/state.ts @@ -8,6 +8,20 @@ export namespace State { blockDetails: Types.BlockDetails; location: Maybe; } + + export interface Settings { + validator: boolean; + implementation: boolean; + peers: boolean; + txs: boolean; + cpu: boolean; + mem: boolean; + blocknumber: boolean; + blockhash: boolean; + blocktime: boolean; + blockpropagation: boolean; + blocklasttime: boolean; + } } export interface State { @@ -19,6 +33,7 @@ export interface State { subscribed: Maybe; chains: Map; nodes: Map; + settings: State.Settings; } export type Update = (changes: Pick | null) => Readonly;