Allow to pin nodes to top of the list (#48)

* Refactored persistent state a bit
* Allow nodes to be pinned to top
This commit is contained in:
Maciej Hirsz
2018-09-24 17:30:39 +02:00
committed by GitHub
parent 44d91a54d5
commit 1559b82eb0
24 changed files with 194 additions and 86 deletions
+18 -9
View File
@@ -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<Connection>;
private settings: Persistent<State.Settings>;
private setSettings: Persistent<State.Settings>['set'];
private readonly settings: PersistentObject<State.Settings>;
private readonly pins: PersistentSet<Types.NodeId>;
private readonly connection: Promise<Connection>;
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<Types.NodeId>('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> {
<div className="App">
<OfflineIndicator status={status} />
<Chains chains={chains} subscribed={subscribed} connection={this.connection} />
<Chain appState={this.state} setSettings={this.setSettings} />
<Chain appState={this.state} settings={this.settings} pins={this.pins} />
</div>
);
}
+8 -4
View File
@@ -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<Connection> {
return new Connection(await Connection.socket(), update);
public static async create(pins: PersistentSet<Types.NodeId>, update: Update): Promise<Connection> {
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<State>;
private readonly update: Update;
private readonly pins: PersistentSet<Types.NodeId>;
constructor(socket: WebSocket, update: Update) {
constructor(socket: WebSocket, update: Update, pins: PersistentSet<Types.NodeId>) {
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);
@@ -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 {
@@ -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<AppState>;
setSettings: Persistent<AppState.Settings>['set'];
settings: PersistentObject<AppState.Settings>;
pins: PersistentSet<Types.NodeId>;
}
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<Chain.Props, Chain.State> {
private renderList() {
const { settings } = this.props.appState;
const { pins } = this.props;
return (
<table className="Chain-node-list">
@@ -133,7 +139,7 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
this
.nodes()
.sort(sortNodes)
.map((node) => <Node.Row key={node.id} node={node} settings={settings} />)
.map((node) => <Node.Row key={node.id} node={node} settings={settings} pins={pins} />)
}
</tbody>
</table>
@@ -164,7 +170,7 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
}
private renderSettings() {
const { settings } = this.props.appState;
const { settings } = this.props;
return (
<div className="Chain-settings">
@@ -177,17 +183,7 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
return null;
}
const checked = settings[setting];
const changeSetting = () => {
const change = {};
change[setting] = !settings[setting];
this.props.setSettings(change);
}
return <Option key={index} onClick={changeSetting} icon={icon} label={label} checked={checked} />;
return <Setting key={index} setting={setting} settings={settings} icon={icon} label={label} />;
})
}
</div>
@@ -7,10 +7,6 @@
position: relative;
}
.Chains .Icon {
margin-right: 1em;
}
.Chains-chain {
padding: 0 12px;
background: #bbb;
@@ -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<Chains.Props, {}> {
public render() {
return (
<div className="Chains">
<Icon src={chainIcon} alt="Observed Chain" />
{
this.chains.map((chain) => this.renderChain(chain))
}
+5 -3
View File
@@ -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() {
@@ -1,5 +1,6 @@
.Node-Row {
color: #999;
cursor: pointer;
}
.Node-Row-synced {
+24 -2
View File
@@ -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<Types.NodeId>;
};
interface HeaderProps {
@@ -39,6 +45,12 @@ interface Column {
export default class Row extends React.Component<RowProps, {}> {
public static readonly columns: Column[] = [
{
label: 'Pin to Top',
icon: pinIcon,
width: 16,
render: ({ pinned }) => <Icon src={pinned ? pinOnIcon : pinOffIcon} />
},
{
label: 'Node',
icon: nodeIcon,
@@ -93,7 +105,7 @@ export default class Row extends React.Component<RowProps, {}> {
}
},
{
label: 'Memory use',
label: 'Memory Use',
icon: memoryIcon,
width: 26,
setting: 'mem',
@@ -177,7 +189,7 @@ export default class Row extends React.Component<RowProps, {}> {
}
return (
<tr className={className}>
<tr className={className} onClick={this.toggle}>
{
Row.columns
.filter(({ setting }) => setting == null || settings[setting])
@@ -186,4 +198,14 @@ export default class Row extends React.Component<RowProps, {}> {
</tr>
);
}
public toggle = () => {
const { pins, node } = this.props;
if (node.pinned) {
pins.delete(node.id)
} else {
pins.add(node.id);
}
}
}
@@ -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<any> {
const className = props.checked ? "Option Option-on" : "Option";
return (
<p className={className} onClick={props.onClick}>
<Icon src={props.icon} alt={props.label} />
{props.label}
<span className="Option-switch">
<span className="Option-knob" />
</span>
</p>
);
}
@@ -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;
}
@@ -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<State.Settings>;
}
}
export class Setting extends React.Component<Setting.Props, {}> {
public render() {
const { icon, label, setting, settings } = this.props;
const checked = settings.get(setting);
const className = checked ? "Setting Setting-on" : "Setting";
return (
<p className={className} onClick={this.toggle}>
<Icon src={icon} alt={label} />
{label}
<span className="Setting-switch">
<span className="Setting-knob" />
</span>
</p>
);
}
private toggle = () => {
const { setting, settings } = this.props;
settings.set(setting, !settings.get(setting));
}
}
+1 -1
View File
@@ -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';
@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="far" data-icon="check-square" class="svg-inline--fa fa-check-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V80c0-26.51-21.49-48-48-48zm0 400H48V80h352v352zm-35.864-241.724L191.547 361.48c-4.705 4.667-12.303 4.637-16.97-.068l-90.781-91.516c-4.667-4.705-4.637-12.303.069-16.971l22.719-22.536c4.705-4.667 12.303-4.637 16.97.069l59.792 60.277 141.352-140.216c4.705-4.667 12.303-4.637 16.97.068l22.536 22.718c4.667 4.706 4.637 12.304-.068 16.971z"></path></svg>

After

Width:  |  Height:  |  Size: 646 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="fas" data-icon="check-square" class="svg-inline--fa fa-check-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 480H48c-26.51 0-48-21.49-48-48V80c0-26.51 21.49-48 48-48h352c26.51 0 48 21.49 48 48v352c0 26.51-21.49 48-48 48zm-204.686-98.059l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.248-16.379-6.249-22.628 0L184 302.745l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.25 16.379 6.25 22.628.001z"></path></svg>

After

Width:  |  Height:  |  Size: 605 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="far" data-icon="circle" class="svg-inline--fa fa-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"></path></svg>

After

Width:  |  Height:  |  Size: 366 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="fas" data-icon="circle" class="svg-inline--fa fa-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z"></path></svg>

After

Width:  |  Height:  |  Size: 283 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="far" data-icon="square" class="svg-inline--fa fa-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-6 400H54c-3.3 0-6-2.7-6-6V86c0-3.3 2.7-6 6-6h340c3.3 0 6 2.7 6 6v340c0 3.3-2.7 6-6 6z"></path></svg>

After

Width:  |  Height:  |  Size: 406 B

@@ -0,0 +1 @@
<svg aria-hidden="true" data-prefix="fas" data-icon="square" class="svg-inline--fa fa-square fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -5,7 +5,7 @@ export class Persistent<Data> {
private readonly key: string;
private value: Data;
constructor(key: string, initial: Data, onChange: (value: Data) => void) {
constructor(key: string, initial: Data, onChange: (value: Readonly<Data>) => void) {
this.key = key;
this.onChange = onChange;
@@ -26,12 +26,12 @@ export class Persistent<Data> {
});
}
public get(): Data {
public get(): Readonly<Data> {
return this.value;
}
public set<K extends keyof Data>(changes: Pick<Data, K> | 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);
}
@@ -0,0 +1,23 @@
import { Persistent } from './';
export class PersistentObject<Data extends object> {
private readonly inner: Persistent<Data>;
constructor(key: string, initial: Data, onChange: (value: Data) => void) {
this.inner = new Persistent(key, initial, onChange);
}
public raw(): Readonly<Data> {
return this.inner.get();
}
public get<K extends keyof Data>(key: K): Data[K] {
return this.inner.get()[key];
}
public set<K extends keyof Data>(key: K, value: Data[K]) {
const data: Data = Object.assign({}, this.raw());
data[key] = value;
this.inner.set(data);
}
}
@@ -0,0 +1,33 @@
import { Persistent } from './';
export class PersistentSet<Item> {
private readonly inner: Persistent<Item[]>;
private value: Set<Item>;
constructor(key: string, onChange: (value: Set<Item>) => void) {
this.inner = new Persistent(key, [], (raw: Readonly<Item[]>) => onChange(this.value = new Set(raw as Item[])));
this.value = new Set(this.inner.get() as Item[]);
}
public get(): Set<Item> {
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);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './Persistent';
export * from './PersistentObject';
export * from './PersistentSet';
+3 -1
View File
@@ -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<Types.ChainLabel>;
chains: Map<Types.ChainLabel, Types.NodeCount>;
nodes: Map<Types.NodeId, State.Node>;
settings: State.Settings;
settings: Readonly<State.Settings>;
pins: Readonly<Set<Types.NodeId>>;
}
export type Update = <K extends keyof State>(changes: Pick<State, K> | null) => Readonly<State>;