diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 5bff38f..483e564 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -2,21 +2,21 @@ 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 { PersistentObject, PersistentSet } from './persist'; import { State } from './state'; import './App.css'; export default class App extends React.Component<{}, State> { public state: State; - private connection: Promise; - private settings: Persistent; - private setSettings: Persistent['set']; + private readonly settings: PersistentObject; + private readonly pins: PersistentSet; + private readonly connection: Promise; constructor(props: {}) { super(props); - this.settings = new Persistent( + this.settings = new PersistentObject( 'settings', { validator: true, @@ -34,7 +34,15 @@ export default class App extends React.Component<{}, State> { (settings) => this.setState({ settings }) ); - this.setSettings = this.settings.set.bind(this.settings); + this.pins = new PersistentSet('pinned', (pins) => { + const { nodes } = this.state; + + for (const node of nodes.values()) { + node.pinned = pins.has(node.id); + } + + this.setState({ nodes, pins }); + }); this.state = { status: 'offline', @@ -45,10 +53,11 @@ export default class App extends React.Component<{}, State> { subscribed: null, chains: new Map(), nodes: new Map(), - settings: this.settings.get(), + settings: this.settings.raw(), + pins: this.pins.get(), }; - this.connection = Connection.create((changes) => { + this.connection = Connection.create(this.pins, (changes) => { if (changes) { this.setState(changes); } @@ -75,7 +84,7 @@ export default class App extends React.Component<{}, State> {
- +
); } diff --git a/packages/frontend/src/Connection.ts b/packages/frontend/src/Connection.ts index 6e47a3a..1ec8395 100644 --- a/packages/frontend/src/Connection.ts +++ b/packages/frontend/src/Connection.ts @@ -1,5 +1,6 @@ import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from '@dotstats/common'; import { State, Update } from './state'; +import { PersistentSet } from './persist'; const { Actions } = FeedMessage; @@ -7,8 +8,8 @@ const TIMEOUT_BASE = (1000 * 5) as Types.Milliseconds; // 5 seconds const TIMEOUT_MAX = (1000 * 60 * 5) as Types.Milliseconds; // 5 minutes export class Connection { - public static async create(update: Update): Promise { - return new Connection(await Connection.socket(), update); + public static async create(pins: PersistentSet, update: Update): Promise { + return new Connection(await Connection.socket(), update, pins); } private static readonly address = window.location.protocol === 'https:' @@ -64,10 +65,12 @@ export class Connection { private socket: WebSocket; private state: Readonly; private readonly update: Update; + private readonly pins: PersistentSet; - constructor(socket: WebSocket, update: Update) { + constructor(socket: WebSocket, update: Update, pins: PersistentSet) { this.socket = socket; this.update = update; + this.pins = pins; this.bindSocket(); } @@ -169,7 +172,8 @@ export class Connection { case Actions.AddedNode: { const [id, nodeDetails, nodeStats, blockDetails, location] = message.payload; - const node = { id, nodeDetails, nodeStats, blockDetails, location }; + const pinned = this.pins.has(id); + const node = { pinned, id, nodeDetails, nodeStats, blockDetails, location }; nodes.set(id, node); diff --git a/packages/frontend/src/components/Chain/Chain.css b/packages/frontend/src/components/Chain/Chain.css index 6fdbe82..6b67682 100644 --- a/packages/frontend/src/components/Chain/Chain.css +++ b/packages/frontend/src/components/Chain/Chain.css @@ -80,7 +80,7 @@ .Chain-node-list th, .Chain-node-list td { text-align: left; - padding: 0.5em 1em; + padding: 0.35em 1em; } .Chain-settings { diff --git a/packages/frontend/src/components/Chain/Chain.tsx b/packages/frontend/src/components/Chain/Chain.tsx index 7a4c92e..01ffdab 100644 --- a/packages/frontend/src/components/Chain/Chain.tsx +++ b/packages/frontend/src/components/Chain/Chain.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { State as AppState } from '../../state'; import { formatNumber, secondsWithPrecision, viewport } from '../../utils'; import { Tab } from './'; -import { Tile, Node, Ago, Option } from '../'; +import { Tile, Node, Ago, Setting } from '../'; import { Types } from '@dotstats/common'; -import { Persistent } from '../../Persistent'; +import { PersistentObject, PersistentSet } from '../../persist'; import blockIcon from '../../icons/package.svg'; import blockTimeIcon from '../../icons/history.svg'; @@ -24,7 +24,8 @@ export namespace Chain { export interface Props { appState: Readonly; - setSettings: Persistent['set']; + settings: PersistentObject; + pins: PersistentSet; } export interface State { @@ -39,12 +40,16 @@ export namespace Chain { } function sortNodes(a: AppState.Node, b: AppState.Node): number { - if (a.blockDetails[0] === b.blockDetails[0]) { - const aPropagation = a.blockDetails[4] == null ? Infinity : a.blockDetails[4] as number; - const bPropagation = b.blockDetails[4] == null ? Infinity : b.blockDetails[4] as number; + if (a.pinned === b.pinned) { + if (a.blockDetails[0] === b.blockDetails[0]) { + const aPropagation = a.blockDetails[4] == null ? Infinity : a.blockDetails[4] as number; + const bPropagation = b.blockDetails[4] == null ? Infinity : b.blockDetails[4] as number; - // Ascending sort by propagation time - return aPropagation - bPropagation; + // Ascending sort by propagation time + return aPropagation - bPropagation; + } + } else { + return Number(b.pinned) - Number(a.pinned); } // Descending sort by block number @@ -124,6 +129,7 @@ export class Chain extends React.Component { private renderList() { const { settings } = this.props.appState; + const { pins } = this.props; return ( @@ -133,7 +139,7 @@ export class Chain extends React.Component { this .nodes() .sort(sortNodes) - .map((node) => ) + .map((node) => ) }
@@ -164,7 +170,7 @@ export class Chain extends React.Component { } private renderSettings() { - const { settings } = this.props.appState; + const { settings } = this.props; return (
@@ -177,17 +183,7 @@ export class Chain extends React.Component { return null; } - const checked = settings[setting]; - - const changeSetting = () => { - const change = {}; - - change[setting] = !settings[setting]; - - this.props.setSettings(change); - } - - return
diff --git a/packages/frontend/src/components/Chains.css b/packages/frontend/src/components/Chains.css index 28dffaa..9fd37d4 100644 --- a/packages/frontend/src/components/Chains.css +++ b/packages/frontend/src/components/Chains.css @@ -7,10 +7,6 @@ position: relative; } -.Chains .Icon { - margin-right: 1em; -} - .Chains-chain { padding: 0 12px; background: #bbb; diff --git a/packages/frontend/src/components/Chains.tsx b/packages/frontend/src/components/Chains.tsx index 8b73520..2cd5bd7 100644 --- a/packages/frontend/src/components/Chains.tsx +++ b/packages/frontend/src/components/Chains.tsx @@ -3,7 +3,6 @@ import { Connection } from '../Connection'; import { Icon } from './Icon'; import { Types, Maybe } from '@dotstats/common'; -import chainIcon from '../icons/link.svg'; import githubIcon from '../icons/mark-github.svg'; import './Chains.css'; @@ -24,7 +23,6 @@ export class Chains extends React.Component { public render() { return (
- { this.chains.map((chain) => this.renderChain(chain)) } diff --git a/packages/frontend/src/components/Icon.tsx b/packages/frontend/src/components/Icon.tsx index ec778af..8560b8f 100644 --- a/packages/frontend/src/components/Icon.tsx +++ b/packages/frontend/src/components/Icon.tsx @@ -4,7 +4,7 @@ import './Icon.css'; export interface Props { src: string; - alt: string; + alt?: string; className?: string; onClick?: () => void; }; @@ -12,8 +12,10 @@ export interface Props { export class Icon extends React.Component<{}, Props> { public props: Props; - public shouldComponentUpdate() { - return false; + public shouldComponentUpdate(nextProps: Props) { + return this.props.src !== nextProps.src + || this.props.alt !== nextProps.alt + || this.props.className !== nextProps.className; } public render() { diff --git a/packages/frontend/src/components/Node/Row.css b/packages/frontend/src/components/Node/Row.css index fb2e148..fbaf041 100644 --- a/packages/frontend/src/components/Node/Row.css +++ b/packages/frontend/src/components/Node/Row.css @@ -1,5 +1,6 @@ .Node-Row { color: #999; + cursor: pointer; } .Node-Row-synced { diff --git a/packages/frontend/src/components/Node/Row.tsx b/packages/frontend/src/components/Node/Row.tsx index 2fba616..b6191f0 100644 --- a/packages/frontend/src/components/Node/Row.tsx +++ b/packages/frontend/src/components/Node/Row.tsx @@ -1,10 +1,15 @@ import * as React from 'react'; import Identicon from 'polkadot-identicon'; +import { Types } from '@dotstats/common'; import { formatNumber, trimHash, milliOrSecond, secondsWithPrecision } from '../../utils'; import { State as AppState } from '../../state'; +import { PersistentSet } from '../../persist'; import { SEMVER_PATTERN } from './'; import { Ago, Icon } from '../'; +import pinIcon from '../../icons/pin.svg'; +import pinOnIcon from '../../icons/check-square-solid.svg'; +import pinOffIcon from '../../icons/square-solid.svg'; import nodeIcon from '../../icons/server.svg'; import nodeValidatorIcon from '../../icons/shield.svg'; import nodeTypeIcon from '../../icons/terminal.svg'; @@ -23,6 +28,7 @@ import './Row.css'; interface RowProps { node: AppState.Node; settings: AppState.Settings; + pins: PersistentSet; }; interface HeaderProps { @@ -39,6 +45,12 @@ interface Column { export default class Row extends React.Component { public static readonly columns: Column[] = [ + { + label: 'Pin to Top', + icon: pinIcon, + width: 16, + render: ({ pinned }) => + }, { label: 'Node', icon: nodeIcon, @@ -93,7 +105,7 @@ export default class Row extends React.Component { } }, { - label: 'Memory use', + label: 'Memory Use', icon: memoryIcon, width: 26, setting: 'mem', @@ -177,7 +189,7 @@ export default class Row extends React.Component { } return ( - + { Row.columns .filter(({ setting }) => setting == null || settings[setting]) @@ -186,4 +198,14 @@ export default class Row extends React.Component { ); } + + public toggle = () => { + const { pins, node } = this.props; + + if (node.pinned) { + pins.delete(node.id) + } else { + pins.add(node.id); + } + } } diff --git a/packages/frontend/src/components/Option.tsx b/packages/frontend/src/components/Option.tsx deleted file mode 100644 index 4d00f23..0000000 --- a/packages/frontend/src/components/Option.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; -import { Icon } from './'; - -import './Option.css'; - -export namespace Option { - export interface Props { - icon: string; - label: string; - checked: boolean; - onClick: () => void; - } -} - -export function Option(props: Option.Props): React.ReactElement { - const className = props.checked ? "Option Option-on" : "Option"; - - return ( -

- - {props.label} - - - -

- ); -} diff --git a/packages/frontend/src/components/Option.css b/packages/frontend/src/components/Setting.css similarity index 81% rename from packages/frontend/src/components/Option.css rename to packages/frontend/src/components/Setting.css index 4a7838d..37b0e01 100644 --- a/packages/frontend/src/components/Option.css +++ b/packages/frontend/src/components/Setting.css @@ -1,19 +1,19 @@ -.Option { +.Setting { color: #666; padding: 0; margin: 0 0 8px 0; cursor: pointer; } -.Option-on { +.Setting-on { color: #fff; } -.Option .Icon { +.Setting .Icon { margin-right: 10px; } -.Option-switch { +.Setting-switch { width: 40px; height: 18px; border-radius: 18px; @@ -24,12 +24,12 @@ transition: background-color 0.15s linear, border-color 0.15s linear; } -.Option-on .Option-switch { +.Setting-on .Setting-switch { background: #d64ca8; border-color: #d64ca8; } -.Option-knob { +.Setting-knob { width: 16px; height: 16px; border: 1px solid #fff; @@ -42,6 +42,6 @@ transition: left 0.15s ease-in-out; } -.Option-on .Option-knob { +.Setting-on .Setting-knob { left: 22px; } diff --git a/packages/frontend/src/components/Setting.tsx b/packages/frontend/src/components/Setting.tsx new file mode 100644 index 0000000..927cd13 --- /dev/null +++ b/packages/frontend/src/components/Setting.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Icon } from './'; +import { State } from '../state'; +import { PersistentObject } from '../persist'; + +import './Setting.css'; + +export namespace Setting { + export interface Props { + icon: string; + label: string; + setting: keyof State.Settings; + settings: PersistentObject; + } +} + +export class Setting extends React.Component { + public render() { + const { icon, label, setting, settings } = this.props; + + const checked = settings.get(setting); + const className = checked ? "Setting Setting-on" : "Setting"; + + return ( +

+ + {label} + + + +

+ ); + } + + private toggle = () => { + const { setting, settings } = this.props; + + settings.set(setting, !settings.get(setting)); + } +} diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 9619de8..88adeb3 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -4,7 +4,7 @@ export * from './Icon'; export * from './Tile'; export * from './Ago'; export * from './OfflineIndicator'; -export * from './Option'; +export * from './Setting'; import * as Node from './Node'; diff --git a/packages/frontend/src/icons/check-square-regular.svg b/packages/frontend/src/icons/check-square-regular.svg new file mode 100644 index 0000000..eb02d8b --- /dev/null +++ b/packages/frontend/src/icons/check-square-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/icons/check-square-solid.svg b/packages/frontend/src/icons/check-square-solid.svg new file mode 100644 index 0000000..56e75ac --- /dev/null +++ b/packages/frontend/src/icons/check-square-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/icons/circle-regular.svg b/packages/frontend/src/icons/circle-regular.svg new file mode 100644 index 0000000..dee554d --- /dev/null +++ b/packages/frontend/src/icons/circle-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/icons/circle-solid.svg b/packages/frontend/src/icons/circle-solid.svg new file mode 100644 index 0000000..2f60c78 --- /dev/null +++ b/packages/frontend/src/icons/circle-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/icons/square-regular.svg b/packages/frontend/src/icons/square-regular.svg new file mode 100644 index 0000000..5b7501f --- /dev/null +++ b/packages/frontend/src/icons/square-regular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/icons/square-solid.svg b/packages/frontend/src/icons/square-solid.svg new file mode 100644 index 0000000..6455b3d --- /dev/null +++ b/packages/frontend/src/icons/square-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/src/Persistent.ts b/packages/frontend/src/persist/Persistent.ts similarity index 78% rename from packages/frontend/src/Persistent.ts rename to packages/frontend/src/persist/Persistent.ts index e0d1dd3..e5ced37 100644 --- a/packages/frontend/src/Persistent.ts +++ b/packages/frontend/src/persist/Persistent.ts @@ -5,7 +5,7 @@ export class Persistent { private readonly key: string; private value: Data; - constructor(key: string, initial: Data, onChange: (value: Data) => void) { + constructor(key: string, initial: Data, onChange: (value: Readonly) => void) { this.key = key; this.onChange = onChange; @@ -26,12 +26,12 @@ export class Persistent { }); } - public get(): Data { + public get(): Readonly { return this.value; } - public set(changes: Pick | Data) { - this.value = Object.assign({}, this.value, changes); + public set(value: Data) { + this.value = value; window.localStorage.setItem(this.key, stringify(this.value) as any as string); this.onChange(this.value); } diff --git a/packages/frontend/src/persist/PersistentObject.ts b/packages/frontend/src/persist/PersistentObject.ts new file mode 100644 index 0000000..83b974f --- /dev/null +++ b/packages/frontend/src/persist/PersistentObject.ts @@ -0,0 +1,23 @@ +import { Persistent } from './'; + +export class PersistentObject { + private readonly inner: Persistent; + + constructor(key: string, initial: Data, onChange: (value: Data) => void) { + this.inner = new Persistent(key, initial, onChange); + } + + public raw(): Readonly { + return this.inner.get(); + } + + public get(key: K): Data[K] { + return this.inner.get()[key]; + } + + public set(key: K, value: Data[K]) { + const data: Data = Object.assign({}, this.raw()); + data[key] = value; + this.inner.set(data); + } +} diff --git a/packages/frontend/src/persist/PersistentSet.ts b/packages/frontend/src/persist/PersistentSet.ts new file mode 100644 index 0000000..bd22f8a --- /dev/null +++ b/packages/frontend/src/persist/PersistentSet.ts @@ -0,0 +1,33 @@ +import { Persistent } from './'; + +export class PersistentSet { + private readonly inner: Persistent; + private value: Set; + + constructor(key: string, onChange: (value: Set) => void) { + this.inner = new Persistent(key, [], (raw: Readonly) => onChange(this.value = new Set(raw as Item[]))); + this.value = new Set(this.inner.get() as Item[]); + } + + public get(): Set { + return this.value; + } + + public add(item: Item) { + this.value.add(item); + this.inner.set(Array.from(this.value)); + } + + public delete(item: Item) { + this.value.delete(item); + this.inner.set(Array.from(this.value)); + } + + public clear() { + this.inner.set([]); + } + + public has(item: Item): boolean { + return this.value.has(item); + } +} diff --git a/packages/frontend/src/persist/index.ts b/packages/frontend/src/persist/index.ts new file mode 100644 index 0000000..8d05b8d --- /dev/null +++ b/packages/frontend/src/persist/index.ts @@ -0,0 +1,3 @@ +export * from './Persistent'; +export * from './PersistentObject'; +export * from './PersistentSet'; diff --git a/packages/frontend/src/state.ts b/packages/frontend/src/state.ts index 80c0b9d..5b0b90c 100644 --- a/packages/frontend/src/state.ts +++ b/packages/frontend/src/state.ts @@ -2,6 +2,7 @@ import { Types, Maybe } from '@dotstats/common'; export namespace State { export interface Node { + pinned: boolean, id: Types.NodeId; nodeDetails: Types.NodeDetails; nodeStats: Types.NodeStats; @@ -33,7 +34,8 @@ export interface State { subscribed: Maybe; chains: Map; nodes: Map; - settings: State.Settings; + settings: Readonly; + pins: Readonly>; } export type Update = (changes: Pick | null) => Readonly;