Grandpa visualization optimizations + improvements (#150)

* Prefix CSS rules properly
* Fix Jdenticon placeholder
* Implement shouldComponentUpdate()
* Force casting of block numbers in backend
* Ensure array is properly sorted
* Fix backfilling and hold only limited blocks in memory
* Use proper ellipsis
* Display note if no grandpa data available yet
* Apply flexing only when two blocks are displayed
* Display consensus icons above all other elements
* Type authoritySetId properly
* Display loading screen only when necessary
* Only measure when necessary
* Reset state on tab change
* Remove tooltips and add keys
* fix: Remove some `any` types, fix list view CSS
* Fix updateState type
* Add keys to more elements
* Limit number of authorities for which vis works
This commit is contained in:
Michael Müller
2019-05-27 17:08:21 +02:00
committed by Maciej Hirsz
parent 26000f3e8a
commit 7add77137a
14 changed files with 527 additions and 322 deletions
+8 -4
View File
@@ -321,7 +321,8 @@ export default class Node {
target_hash: targetHash,
} = message;
const voter = this.extractVoter(message.voter);
this.events.emit('afg-received-precommit', targetNumber, targetHash, voter);
const number = parseInt(String(targetNumber), 10) as Types.BlockNumber;
this.events.emit('afg-received-precommit', number, targetHash, voter);
}
private onAfgReceivedPrevote(message: AfgReceivedPrevote) {
@@ -330,7 +331,8 @@ export default class Node {
target_hash: targetHash,
} = message;
const voter = this.extractVoter(message.voter);
this.events.emit('afg-received-prevote', targetNumber, targetHash, voter);
const number = parseInt(String(targetNumber), 10) as Types.BlockNumber;
this.events.emit('afg-received-prevote', number, targetHash, voter);
}
private onAfgAuthoritySet(message: AfgAuthoritySet) {
@@ -346,7 +348,8 @@ export default class Node {
if (JSON.stringify(this.authorities) !== String(message.authorities) ||
this.authoritySetId !== authoritySetId) {
this.events.emit('authority-set-changed', authorities, authoritySetId, number, hash);
const no = parseInt(String(number), 10) as Types.BlockNumber;
this.events.emit('authority-set-changed', authorities, authoritySetId, no, hash);
}
}
@@ -355,7 +358,8 @@ export default class Node {
finalized_number: finalizedNumber,
finalized_hash: finalizedHash,
} = message;
this.events.emit('afg-finalized', finalizedNumber, finalizedHash);
const number = parseInt(String(finalizedNumber), 10) as Types.BlockNumber;
this.events.emit('afg-finalized', number, finalizedHash);
}
private extractVoter(message_voter: String): Types.Address {
+1 -1
View File
@@ -9,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 = 24 as Types.FeedVersion;
export const VERSION: Types.FeedVersion = 22 as Types.FeedVersion;
+2 -1
View File
@@ -40,7 +40,8 @@ export declare type Authority = {
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 ConsensusInfo = Array<[BlockNumber, ConsensusView]>;
export declare type ConsensusItem = [BlockNumber, ConsensusView];
export declare type ConsensusInfo = Array<ConsensusItem>;
export declare type ConsensusView = Map<Address, ConsensusState>;
export declare type ConsensusState = Map<Address, ConsensusDetail>;
export declare type ConsensusDetail = {
+170 -115
View File
@@ -1,13 +1,15 @@
import { Types, Maybe } from '@dotstats/common';
import { State } from './state';
import { Types } from '@dotstats/common';
import { State, UpdateBound } from './state';
const BLOCKS_LIMIT = 50;
export class AfgHandling {
private updateState: (state: any) => void;
private getState: () => State;
private updateState: UpdateBound;
private getState: () => Readonly<State>;
constructor(
updateState: (state: any) => void,
getState: () => State,
updateState: UpdateBound,
getState: () => Readonly<State>,
) {
this.updateState = updateState;
this.getState = getState;
@@ -17,9 +19,22 @@ export class AfgHandling {
authoritySetId: Types.AuthoritySetId,
authorities: Types.Authorities,
) {
if (authoritySetId !== this.getState().authoritySetId) {
if (this.getState().authoritySetId != null && authoritySetId !== this.getState().authoritySetId) {
// the visualization is restarted when we receive a new auhority set
this.updateState({authoritySetId, authorities, consensusInfo: []});
this.updateState({
authoritySetId,
authorities,
consensusInfo: [],
displayConsensusLoadingScreen: false,
});
} else if (this.getState().authoritySetId == null) {
// initial display
this.updateState({
authoritySetId,
authorities,
consensusInfo: [],
displayConsensusLoadingScreen: true,
});
}
return null;
}
@@ -29,19 +44,58 @@ export class AfgHandling {
finalizedNumber: Types.BlockNumber,
finalizedHash: Types.BlockHash,
) {
const consensusInfo = this.getState().consensusInfo;
this.markFinalized(addr, finalizedNumber, finalizedHash);
const state = this.getState();
if (finalizedNumber < state.best - BLOCKS_LIMIT) {
return;
};
const op = (i: Types.BlockNumber, view: Types.ConsensusView) => {
const consensusDetail = view[addr][addr];
const data = {
Finalized: true,
FinalizedHash: finalizedHash,
FinalizedHeight: finalizedNumber,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
} as Types.ConsensusDetail;
this.initialiseConsensusView(state.consensusInfo, finalizedNumber, addr, addr);
this.updateConsensusInfo(state.consensusInfo, finalizedNumber, addr, addr, data as Partial<Types.ConsensusDetail>);
// Finalizing a block implicitly includes finalizing all
// preceding blocks. This function marks the preceding
// blocks as implicitly finalized on and stores a pointer
// to the block which contains the explicit finalization.
const op = (i: Types.BlockNumber, index: number) : boolean => {
const consensusDetail = state.consensusInfo[index][1][addr][addr];
if (consensusDetail.Finalized || consensusDetail.ImplicitFinalized) {
return false;
}
this.markImplicitlyFinalized(i, addr, finalizedNumber, addr);
state.consensusInfo[index][1][addr][addr] = {
Finalized: true,
FinalizedHeight: i,
ImplicitFinalized: true,
ImplicitPointer: finalizedNumber,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
ImplicitPrevote: true,
ImplicitPrecommit: true,
};
return true;
};
this.backfill(consensusInfo, finalizedNumber, op, addr, addr);
this.backfill(state.consensusInfo, finalizedNumber, op, addr, addr);
this.pruneBlocks(state.consensusInfo);
this.updateState({consensusInfo: state.consensusInfo});
}
public receivedPre(
@@ -51,95 +105,47 @@ export class AfgHandling {
voter: Types.Address,
what: string,
) {
const data = what === "prevote" ? { Prevote: true } : { Precommit: true };
this.updateConsensusInfo(height, addr, voter, data as Partial<Types.ConsensusDetail>);
const state = this.getState();
if (height < state.best - BLOCKS_LIMIT) {
return;
};
const op = (i: Types.BlockNumber, view: Types.ConsensusView) => {
const consensusDetail = view[addr][voter];
if (consensusDetail.Prevote || consensusDetail.ImplicitPrevote) {
const data = what === "prevote" ? { Prevote: true } : { Precommit: true };
this.initialiseConsensusView(state.consensusInfo, height, addr, voter);
this.updateConsensusInfo(state.consensusInfo, height, addr, voter, data as Partial<Types.ConsensusDetail>);
// A Prevote or Precommit on a block implicitly includes
// a vote on all preceding blocks. This function marks
// the preceding blocks as implicitly voted on and stores
// a pointer to the block which contains the explicit vote.
const op = (i: Types.BlockNumber, index: number) : boolean => {
const consensusDetail = state.consensusInfo[index][1][addr][voter];
if (what === "prevote" && (consensusDetail.Prevote || consensusDetail.ImplicitPrevote)) {
return false;
}
if (what === "precommit" && (consensusDetail.Precommit || consensusDetail.ImplicitPrecommit)
// because of extrapolation a prevote needs to be set as well.
// if it is not we continue backfilling (and set it during that process).
&& (consensusDetail.Prevote || consensusDetail.ImplicitPrevote)) {
return false;
}
this.markImplicitlyPre(i, addr, height, what, voter);
if (what === "prevote") {
consensusInfo[index][1][addr][voter].ImplicitPrevote = true;
} else if (what === "precommit") {
consensusInfo[index][1][addr][voter].ImplicitPrecommit = true;
// Extrapolate. Precommit implies Prevote.
consensusInfo[index][1][addr][voter].ImplicitPrevote = true;
}
consensusInfo[index][1][addr][voter].ImplicitPointer = height;
return true;
};
this.backfill(this.getState().consensusInfo, height, op, addr, voter);
}
private markFinalized(
addr: Types.Address,
finalizedHeight: Types.BlockNumber,
finalizedHash: Types.BlockHash,
) {
const data = {
Finalized: true,
FinalizedHash: finalizedHash,
FinalizedHeight: finalizedHeight,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
} as Types.ConsensusDetail;
this.updateConsensusInfo(finalizedHeight, addr, addr, data);
}
// A Prevote or Precommit on a block implicitly includes
// a vote on all preceding blocks. This function marks
// the preceding blocks as implicitly voted on and stores
// a pointer to the block which contains the explicit vote.
private markImplicitlyPre(
height: Types.BlockNumber,
addr: Types.Address,
where: Types.BlockNumber,
what: string,
voter: Types.Address,
) {
const consensusInfo = this.getState().consensusInfo;
const consensusView = this.initialiseConsensusView(consensusInfo, height, addr, voter);
if (what === "prevote") {
consensusView[addr][voter].ImplicitPrevote = true;
} else if (what === "precommit") {
consensusView[addr][voter].ImplicitPrecommit = true;
}
consensusView[addr][voter].ImplicitPointer = where;
this.updateState({consensusInfo});
}
// Finalizing a block implicitly includes finalizing all
// preceding blocks. This function marks the preceding
// blocks as implicitly finalized on and stores a pointer
// to the block which contains the explicit finalization.
private markImplicitlyFinalized(
height: Types.BlockNumber,
addr: Types.Address,
to: Types.BlockNumber,
voter: Types.Address,
) {
const consensusInfo = this.getState().consensusInfo;
const consensusView = this.initialiseConsensusView(consensusInfo, height, addr, voter);
const consensusDetail = {
Finalized: true,
FinalizedHeight: height,
ImplicitFinalized: true,
ImplicitPointer: to,
// this is extrapolated. if this app was just started up we
// might not yet have received prevotes/precommits. but
// those are a necessary precondition for finalization, so
// we can set them and display them in the ui.
Prevote: true,
Precommit: true,
ImplicitPrevote: true,
ImplicitPrecommit: true,
};
consensusView[addr][voter] = consensusDetail;
this.backfill(consensusInfo, height, op, addr, voter);
this.pruneBlocks(consensusInfo);
this.updateState({consensusInfo});
}
@@ -149,21 +155,27 @@ export class AfgHandling {
height: Types.BlockNumber,
own: Types.Address,
other: Types.Address,
) : Types.ConsensusView {
) {
const found =
consensusInfo.find(([blockNumber,]) => blockNumber === height);
let consensusView;
if (found) {
[, consensusView] = found;
this.initialiseConsensusViewByRef(consensusView, own, other);
} else {
consensusView = {} as Types.ConsensusView;
consensusInfo.unshift([height, consensusView]);
this.initialiseConsensusViewByRef(consensusView, own, other);
const item: Types.ConsensusItem = [height, consensusView];
const insertPos = consensusInfo.findIndex(([elHeight, elView]) => elHeight < height);
if (insertPos >= 0) {
consensusInfo.splice(insertPos, 0, item);
} else {
consensusInfo.push(item);
}
}
this.initialiseConsensusViewByRef(consensusView, own, other);
return consensusView;
}
// Initializes the `ConsensusView` with empty objects.
@@ -188,41 +200,84 @@ export class AfgHandling {
// Returns block number until which we backfilled.
private backfill(
consensusInfo: Types.ConsensusInfo,
to: Types.BlockNumber,
f: Maybe<(i: Types.BlockNumber, consensusView: Types.ConsensusView) => boolean>,
start: Types.BlockNumber,
f: (i: Types.BlockNumber, index: number) => boolean,
own: Types.Address,
other: Types.Address,
): Types.BlockNumber {
for (const [height, consensusView] of consensusInfo) {
if (height >= to) {
continue;
) {
// if this is the first block then we don't fill latter blocks
// if there is only one block, then it also doesn't make
// sense to backfill, because we could potentially backfill
// until 0 (which could be unfortunate if the first received
// block is e.g. 28317.
if (consensusInfo.length < 2) {
return;
}
let firstBlockNumber = consensusInfo[consensusInfo.length - 1][0];
const limit = this.getState().best - BLOCKS_LIMIT;
if (firstBlockNumber < limit) {
firstBlockNumber = limit as Types.BlockNumber;
}
if (start - 1 < firstBlockNumber) {
// if the first block which would be backfilled is already
// less than the first block number we can abort.
//
// this can happen if e.g. one authority is hanging behind,
// most of them could e.g. be at 3000 and one is hanging behind
// and sending info for 2000. then we can't start backfilling
// from 2000.
return;
}
let counter = 0;
while (start-- > 0) {
counter++;
if (counter >= BLOCKS_LIMIT) {
break;
}
this.initialiseConsensusViewByRef(consensusView, own, other);
const cont = f ? f(height, consensusView) : true;
const startBlockNumber = start as Types.BlockNumber;
this.initialiseConsensusView(consensusInfo, startBlockNumber, own, other);
const index =
consensusInfo.findIndex(([blockNumber,]) => blockNumber === start);
const cont = f(start, index);
if (!cont) {
break;
}
// we don't want to fill into nirvana
const firstBlockReached = startBlockNumber <= firstBlockNumber;
if (firstBlockReached) {
break;
}
}
return to;
}
private updateConsensusInfo(
consensusInfo: Types.ConsensusInfo,
height: Types.BlockNumber,
addr: Types.Address,
voter: Types.Address,
data: Partial<Types.ConsensusDetail>,
) {
const consensusInfo = this.getState().consensusInfo;
const consensusView = this.initialiseConsensusView(consensusInfo, height, addr, voter);
const found =
consensusInfo.findIndex(([blockNumber,]) => blockNumber === height);
if (found < 0) {
return;
}
for (const k in data) {
if (data.hasOwnProperty(k)) {
consensusView[addr][voter][k] = data[k];
consensusInfo[found][1][addr][voter][k] = data[k];
}
}
}
this.updateState({consensusInfo});
private pruneBlocks(consensusInfo: Types.ConsensusInfo) {
if (consensusInfo.length >= BLOCKS_LIMIT) {
consensusInfo.length = BLOCKS_LIMIT;
}
}
}
+10 -2
View File
@@ -54,8 +54,9 @@ export default class App extends React.Component<{}, State> {
best: 0 as Types.BlockNumber,
finalized: 0 as Types.BlockNumber,
consensusInfo: new Array() as Types.ConsensusInfo,
displayConsensusLoadingScreen: true,
authorities: new Array() as Types.Authorities,
authoritySetId: -1 as Types.AuthoritySetId,
authoritySetId: null,
sendFinality: false,
blockTimestamp: 0 as Types.Timestamp,
blockAverage: null,
@@ -65,6 +66,7 @@ export default class App extends React.Component<{}, State> {
nodes: new SortedCollection(Node.compare),
settings: this.settings.raw(),
pins: this.pins.get(),
tabChanged: false,
};
this.connection = Connection.create(this.pins, (changes) => {
@@ -85,7 +87,7 @@ export default class App extends React.Component<{}, State> {
return (
<div className="App App-no-telemetry">
<OfflineIndicator status={status} />
Waiting for telemetry data...
Waiting for telemetry&hellip;
</div>
);
}
@@ -99,6 +101,12 @@ export default class App extends React.Component<{}, State> {
);
}
public componentDidUpdate() {
if (this.state.tabChanged === true) {
this.setState({tabChanged: false});
}
}
public componentWillMount() {
window.addEventListener('keydown', this.onKeyPress);
}
+33 -10
View File
@@ -1,5 +1,5 @@
import { VERSION, timestamp, FeedMessage, Types, Maybe, sleep } from '@dotstats/common';
import { State, Update, Node } from './state';
import { State, Update, Node, UpdateBound } from './state';
import { PersistentSet } from './persist';
import { getHashData, setHashData } from './utils';
import { AfgHandling } from './AfgHandling';
@@ -14,11 +14,13 @@ export class Connection {
return new Connection(await Connection.socket(), update, pins);
}
private static readonly address = window.location.protocol === 'https:'
private static readonly debug: number = 2;
private static readonly address1 = window.location.protocol === 'https:'
? `wss://${window.location.hostname}/feed/`
: `ws://${window.location.hostname}:8080`;
// private static readonly address = 'wss://telemetry.polkadot.io/feed/';
private static readonly address2 = 'wss://telemetry.polkadot.io/feed/';
private static async socket(): Promise<WebSocket> {
let socket = await Connection.trySocket();
@@ -52,7 +54,7 @@ export class Connection {
resolve(null);
}
const socket = new WebSocket(Connection.address);
const socket = new WebSocket(Connection.debug === 1 ? Connection.address1 : Connection.address2);
socket.addEventListener('open', onSuccess);
socket.addEventListener('error', onFailure);
@@ -78,7 +80,15 @@ export class Connection {
}
public subscribe(chain: Types.ChainLabel) {
setHashData({ chain });
if (this.state.subscribed != null && this.state.subscribed !== chain) {
this.state = this.update({
tabChanged: true,
});
setHashData({ chain, tab: 'list' });
} else {
setHashData({ chain });
}
this.socket.send(`subscribe:${chain}`);
}
@@ -88,8 +98,16 @@ export class Connection {
this.socket.send(`send-finality:${chain}`);
}
public resetConsensus() {
this.state = this.update({
consensusInfo: new Array() as Types.ConsensusInfo,
displayConsensusLoadingScreen: true,
authorities: [] as Types.Address[],
authoritySetId: null,
});
}
public unsubscribeConsensus(chain: Types.ChainLabel) {
setHashData({ chain });
this.resubscribeSendFinality = true;
this.socket.send(`no-more-finality:${chain}`);
}
@@ -98,7 +116,7 @@ export class Connection {
const { nodes, chains } = this.state;
const ref = nodes.ref();
const updateState = (state: any) => { this.state = this.update(state); };
const updateState: UpdateBound = (state) => { this.state = this.update(state); };
const getState = () => this.state;
const afg = new AfgHandling(updateState, getState);
@@ -217,6 +235,7 @@ export class Connection {
if (this.state.subscribed === message.payload) {
nodes.clear();
this.state = this.update({ subscribed: null, nodes, chains });
this.resetConsensus();
}
break;
@@ -248,21 +267,24 @@ export class Connection {
case Actions.AfgFinalized: {
const [nodeAddress, finalizedNumber, finalizedHash] = message.payload;
afg.receivedFinalized( nodeAddress, finalizedNumber, finalizedHash);
const no = parseInt(String(finalizedNumber), 10) as Types.BlockNumber;
afg.receivedFinalized( nodeAddress, no, finalizedHash);
break;
}
case Actions.AfgReceivedPrevote: {
const [nodeAddress, blockNumber, blockHash, voter] = message.payload;
afg.receivedPre(nodeAddress, blockNumber, blockHash, voter, "prevote");
const no = parseInt(String(blockNumber), 10) as Types.BlockNumber;
afg.receivedPre(nodeAddress, no, blockHash, voter, "prevote");
break;
}
case Actions.AfgReceivedPrecommit: {
const [nodeAddress, blockNumber, blockHash, voter] = message.payload;
afg.receivedPre(nodeAddress, blockNumber, blockHash, voter, "precommit");
const no = parseInt(String(blockNumber), 10) as Types.BlockNumber;
afg.receivedPre(nodeAddress, no, blockHash, voter, "precommit");
break;
}
@@ -393,6 +415,7 @@ export class Connection {
private handleDisconnect = async () => {
this.state = this.update({ status: 'offline' });
this.resetConsensus();
this.clean();
this.socket.close();
this.socket = await Connection.socket();
@@ -56,6 +56,13 @@ export class Chain extends React.Component<Chain.Props, Chain.State> {
};
}
public shouldComponentUpdate(nextProps: Chain.Props, nextState: Chain.State): boolean {
if (nextProps.appState.tabChanged === true && nextState.display === 'consensus') {
this.setDisplay('list');
}
return true;
}
public render() {
const { appState } = this.props;
const { best, finalized, blockTimestamp, blockAverage } = appState;
@@ -61,5 +61,6 @@ export class Chains extends React.Component<Chains.Props, {}> {
const connection = await this.props.connection;
connection.subscribe(chain);
connection.resetConsensus();
}
}
@@ -1,30 +1,30 @@
.ConsensusList {
.Consensus .ConsensusList {
opacity: 0.0; /* the box should only show up once flexing has been applied */
}
.ConsensusList table {
.Consensus .ConsensusList table {
border-spacing: 0px;
}
.flexContainerLargeRow {
.Consensus .flexContainerLargeRow {
display: flex;
align-items: stretch;
flex-direction: row;
opacity: 1.0;
}
.flexContainerLargeRow .firstInRow {
.Consensus .flexContainerLargeRow .firstInRow {
width: 100%
}
.flexContainerLargeRow .firstInRow .emptylegend,
.flexContainerLargeRow .firstInRow .nameLegend {
.Consensus .flexContainerLargeRow .firstInRow .emptylegend,
.Consensus .flexContainerLargeRow .firstInRow .nameLegend {
width: 99%;
flex-grow: 1000000000;
align-self: stretch;
}
.flexContainerSmallRow {
.Consensus .flexContainerSmallRow {
display: flex;
align-items: stretch;
flex-direction: row;
@@ -32,20 +32,20 @@
opacity: 1.0;
}
.flexContainerSmallRow div {
.Consensus .flexContainerSmallRow div {
align-self: stretch;
flex: 1;
}
.flexContainerSmallRow table .legend {
.Consensus .flexContainerSmallRow table .legend {
width: 100%;
}
.ConsensusList {
.Consensus .ConsensusList {
margin-bottom: 2px;
}
.allRows {
.Consensus {
width: 100%;
min-width: 1350px;
min-height: 100%;
@@ -54,91 +54,114 @@
left: 0px;
}
.SmallRow {
.Consensus .SmallRow {
float: left;
clear: both;
font-size: 8px !important;
width: 100%;
}
.SmallRow svg {
.Consensus .SmallRow svg {
width: 14px;
height: 14px;
}
.hatching svg {
.Consensus .hatching svg {
width: 12px !important;
height: 12px !important;
}
.SmallRow .hatching svg {
.Consensus .SmallRow .hatching svg {
width: 10px !important;
height: 10px !important;
}
.matrixXLegend .Tooltip-container {
.Consensus .matrixXLegend .Tooltip-container {
height: auto !important;
}
.legend {
.Consensus .legend {
text-align: center !important;
}
.nameLegend {
.Consensus .nameLegend {
border-right: none;
border-bottom: 1px dotted #555;
}
.SmallRow .nameLegend {
.Consensus .SmallRow .nameLegend {
display: none;
}
.SmallRow .finalizedInfo .Tooltip-container {
.Consensus .SmallRow .finalizedInfo .Tooltip-container {
float: none;
display: inline-block !important;
vertical-align: middle;
}
.SmallRow .finalizedInfo {
.Consensus .SmallRow .finalizedInfo {
min-height: 40px;
min-width: 40px;
}
.SmallRow .explicit,
.SmallRow .implicit {
.Consensus .SmallRow .explicit,
.Consensus .SmallRow .implicit {
height: 12px;
}
.SmallRow .finalizedInfo .explicit,
.SmallRow .finalizedInfo .implicit {
.Consensus .SmallRow .finalizedInfo .explicit,
.Consensus .SmallRow .finalizedInfo .implicit {
margin-right: 6px;
}
.nodeAddress {
.Consensus .nodeAddress {
margin-top: 4px;
}
.first_false .legend .nodeAddress,
.SmallRow .legend .nodeAddress,
th.finalizedInfo .Tooltip-container {
.Consensus .first_false .legend .nodeAddress,
.Consensus .SmallRow .legend .nodeAddress,
.Consensus th.finalizedInfo .Tooltip-container {
float: none !important;
text-align: center !important;
}
.noStretchOnLastRow::after {
.Consensus .noStretchOnLastRow::after {
content: '';
flex-grow: 1000000000;
}
.flexContainerLargeRow .noStretchOnLastRow .firstInRow table {
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow table {
width: auto !important;
}
.flexContainerLargeRow .noStretchOnLastRow .firstInRow .emptylegend {
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow .emptylegend {
width: auto !important;
}
.flexContainerLargeRow .noStretchOnLastRow .firstInRow {
.Consensus .flexContainerLargeRow .noStretchOnLastRow .firstInRow {
width: auto !important;
}
/* similar to .App-no-telemetry */
.Consensus .noData {
width: 100vw;
line-height: 60vh;
font-size: 56px;
font-weight: 100;
text-align: center;
color: #888;
}
/* similar to .App-no-telemetry */
.Consensus .tooManyAuthorities {
width: 100vw;
line-height: 20vh;
font-size: 56px;
font-weight: 100;
text-align: center;
color: #888;
}
.Consensus svg {
z-index: 999999999;
}
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Types } from '@dotstats/common';
import { Types, Maybe } from '@dotstats/common';
import { Connection } from '../../Connection';
import Measure, {BoundingRect, ContentRect} from 'react-measure';
@@ -8,6 +8,8 @@ import { State as AppState } from '../../state';
import './Consensus.css';
const AUTHORITIES_LIMIT = 10;
export namespace Consensus {
export interface Props {
appState: Readonly<AppState>;
@@ -26,6 +28,7 @@ export namespace Consensus {
smallBlocksRows: number,
countBlocksInSmallRow: number,
smallRowsAddFlexClass: boolean,
lastConsensusInfo: string,
}
}
@@ -43,8 +46,56 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
smallBlocksRows: 1,
countBlocksInSmallRow: 1,
smallRowsAddFlexClass: false,
lastConsensusInfo: "",
};
public shouldComponentUpdate(nextProps: Consensus.Props, nextState: Consensus.State): boolean {
if (this.props.appState.authorities.length === 0 && nextProps.appState.authorities.length === 0) {
return false;
}
this.calculateBoxCount(false);
// size detected, but flex class has not yet been added
const largeBlocksSizeDetected = this.largeBlocksSizeDetected(nextState) === true &&
this.state.largeRowsAddFlexClass === false;
if (largeBlocksSizeDetected) {
return true;
}
const smallBlocksSizeDetected = this.smallBlocksSizeDetected(nextState) === true &&
this.state.smallRowsAddFlexClass === false;
if (smallBlocksSizeDetected) {
return true;
}
const windowSizeChanged = JSON.stringify(this.state.dimensions) !==
JSON.stringify(nextState.dimensions);
if (windowSizeChanged) {
return true;
}
const newConsensusInfoAvailable = this.state.lastConsensusInfo !==
JSON.stringify(nextProps.appState.consensusInfo);
if (newConsensusInfoAvailable) {
return true;
}
const authoritySetIdDidChange = this.props.appState.authoritySetId !==
nextProps.appState.authoritySetId;
if (authoritySetIdDidChange) {
return true;
}
const authoritiesDidChange = JSON.stringify(this.props.appState.authorities) !==
JSON.stringify(nextProps.appState.authorities);
if (authoritiesDidChange) {
return true;
}
return false;
}
public componentDidMount() {
if (this.props.appState.subscribed != null) {
const chain = this.props.appState.subscribed;
@@ -60,9 +111,12 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
}
public largeBlocksSizeDetected(state: Consensus.State): boolean {
const countBlocks = Object.keys(this.props.appState.consensusInfo).length;
if (countBlocks === 1) {
return state.largeBlockWithLegend.width > -1 && state.largeBlockWithLegend.height > -1;
// we can only state that we detected the two block sizes (with
// legend and without) if at least two blocks have been added:
// the first displayed block will always have a legend with the
// node names attached, the second not.
if (this.props.appState.consensusInfo.length < 2) {
return false;
}
// if there is more than one block then the size of the first block (with legend)
@@ -108,10 +162,31 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
}
public render() {
this.calculateBoxCount(false);
this.state.lastConsensusInfo = JSON.stringify(this.props.appState.consensusInfo);
const lastBlocks = this.props.appState.consensusInfo;
if (this.props.appState.authorities.length > AUTHORITIES_LIMIT) {
return <div className="Consensus">
<div className="tooManyAuthorities">
<p>
Too many authorities.</p>
<p>
Won't display for more than {AUTHORITIES_LIMIT} authorities
to protect your browser.
</p>
</div>;
</div>;
}
if (this.props.appState.displayConsensusLoadingScreen && lastBlocks.length < 2) {
return <div className="Consensus">
<div className="noData">
{lastBlocks.length === 0 ? "No " : "Not yet enough "}
GRANDPA data received by the authorities&hellip;
</div>;
</div>;
}
let from = 0;
let to = this.state.countBlocksInLargeRow;
const firstLargeRow = this.getLargeRow(lastBlocks.slice(from, to), 0);
@@ -124,19 +199,24 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
to = to + (this.state.smallBlocksRows * this.state.countBlocksInSmallRow);
const smallRow = this.getSmallRow(lastBlocks.slice(from, to));
return (
<React.Fragment>
<Measure bounds={true} onResize={this.handleOnResize}>
{({ measureRef }) => (
<div className="allRows" ref={measureRef}>
{firstLargeRow}
{secondLargeRow}
{smallRow}
</div>
)}
</Measure>
</React.Fragment>
);
const get = (measureRef: Maybe<(ref: Element | null) => void>) =>
<div className="Consensus" ref={measureRef} key="Consensus">
{firstLargeRow}
{secondLargeRow}
{smallRow}
</div>;
if (!(this.state.smallRowsAddFlexClass && this.state.largeRowsAddFlexClass)) {
return (
<React.Fragment>
<Measure bounds={true} onResize={this.handleOnResize}>
{({measureRef}) => get(measureRef)}
</Measure>
</React.Fragment>
);
} else {
return (get(null));
}
}
private handleOnResize = (contentRect: ContentRect) => {
@@ -187,6 +267,7 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
compact={false}
key={height}
height={height}
measure={!this.state.largeRowsAddFlexClass}
consensusView={consensusView}
authorities={this.getAuthorities()}
authoritySetId={this.props.appState.authoritySetId}
@@ -227,6 +308,7 @@ export class Consensus extends React.Component<Consensus.Props, {}> {
compact={true}
key={height}
height={height}
measure={!this.state.smallRowsAddFlexClass}
consensusView={consensusView}
authorities={this.getAuthorities()}
authoritySetId={this.props.appState.authoritySetId} />;
@@ -1,4 +1,4 @@
.BlockConsensusMatrice {
.Consensus .BlockConsensusMatrice {
background-color: #222;
font-family: monospace, sans-serif;
border-spacing: 0px;
@@ -6,144 +6,144 @@
border-bottom: 1px solid #999;
}
.LargeRow .BlockConsensusMatrice:last-child {
.Consensus .LargeRow .BlockConsensusMatrice:last-child {
border-right: none;
}
.SmallRow .lastInRow {
.Consensus .SmallRow .lastInRow {
clear: right;
width: 99%;
page-break-after: always;
}
.BlockConsensusMatrice th {
.Consensus .BlockConsensusMatrice th {
font-weight: normal;
border-bottom: 1px dashed #999;
}
.finalizedInfo, .legend {
.Consensus .finalizedInfo, .legend {
border-bottom: 1px dotted #555555;
}
.finalizedInfo {
.Consensus .finalizedInfo {
white-space: nowrap;
}
.finalizedInfo .Tooltip-container {
.Consensus .finalizedInfo .Tooltip-container {
display: inline-block;
white-space: nowrap;
vertical-align: middle;
}
.BlockConsensusMatrice .matrice {
.Consensus .BlockConsensusMatrice .matrice {
width: 28px;
height: 28px;
}
.BlockConsensusMatrice .matrice {
.Consensus .BlockConsensusMatrice .matrice {
font-weight: normal;
border-right: 1px dotted #555555;
border-bottom: 1px dotted #555;
}
.BlockConsensusMatrice tr .matrice:last-child {
.Consensus .BlockConsensusMatrice tr .matrice:last-child {
border-right: none;
}
.BlockConsensusMatrice .matrixXLegend {
.Consensus .BlockConsensusMatrice .matrixXLegend {
text-align: center;
border-right: 1px dotted #555555;
}
.BlockConsensusMatrice .matrixXLegend:last-child {
.Consensus .BlockConsensusMatrice .matrixXLegend:last-child {
border-right: none;
}
.matrice {
.Consensus .matrice {
text-align: center !important;
min-width: 35px;
}
.SmallRow .matrixXLegend,
.SmallRow .matrice {
.Consensus .SmallRow .matrixXLegend,
.Consensus .SmallRow .matrice {
min-width: 26px;
min-height: 26px;
}
.finalizedInfo {
.Consensus .finalizedInfo {
text-align: center !important;
}
.SmallRow .finalizedInfo {
.Consensus .SmallRow .finalizedInfo {
min-width: 40px;
}
.finalizedInfo {
.Consensus .finalizedInfo {
text-align: right;
border-right: 1px dashed #999;
min-width: 48px;
min-width: 50px;
}
.finalizedInfo .Tooltip-container {
.Consensus .finalizedInfo .Tooltip-container {
float: none;
}
.explicit {
.Consensus .explicit {
fill: #E70E81;
}
.nodeName {
.Consensus .nodeName {
float: left;
padding-right: 10px;
padding-top: 4px;
}
.flexContainerLargeRow .firstInRow .nodeContent {
.Consensus .flexContainerLargeRow .firstInRow .nodeContent {
white-space: nowrap;
}
.flexContainerLargeRow .firstInRow .nodeName {
.Consensus .flexContainerLargeRow .firstInRow .nodeName {
display: inline-block !important;
float: none !important;
vertical-align: middle;
margin-bottom: 3px;
}
.flexContainerLargeRow .firstInRow .nodeAddress {
.Consensus .flexContainerLargeRow .firstInRow .nodeAddress {
display: inline-block !important;
float: none !important;
vertical-align: middle;
margin-right: 3px;
}
.legend {
.Consensus .legend {
border-right: 1px solid #999;
white-space: nowrap;
}
.first_false .nodeName {
.Consensus .first_false .nodeName {
display: none;
}
.legend .nodeAddress {
.Consensus .legend .nodeAddress {
float: right;
}
.Row {
.Consensus .Row {
color: #999;
cursor: pointer;
}
.Row th, .Row td {
.Consensus .Row th, .Consensus .Row td {
text-align: left;
padding: 2px;
}
.Row td {
.Consensus .Row td {
position: relative;
}
.Row .Row-truncate {
.Consensus .Row .Row-truncate {
position: absolute;
left: 0;
right: 0;
@@ -154,48 +154,49 @@
text-overflow: ellipsis;
}
.Row .Row-Tooltip {
.Consensus .Row .Row-Tooltip {
position: initial;
padding: inherit;
}
.Row:hover {
.Consensus .Row:hover {
background-color: #161616;
}
.nodeAddress svg {
.Consensus .nodeAddress svg {
cursor: pointer;
}
.nodeAddress svg:hover {
.Consensus .nodeAddress svg:hover {
transform: scale(2);
}
.matrice .Icon ~ .Icon {
.Consensus .matrice .Icon ~ .Icon {
margin-left: -4px;
}
.SmallRow .matrice .Prevote svg {
.Consensus .SmallRow .matrice .Prevote svg {
margin-left: 3px;
margin-bottom: -11px;
}
.SmallRow .matrice .Precommit svg {
.Consensus .SmallRow .matrice .Precommit svg {
margin-left: -1px;
margin-top: -6px;
margin-bottom: 0px;
}
.jdenticonPlaceholder {
.Consensus .jdenticonPlaceholder {
width: 28px;
float: right;
}
.SmallRow .jdenticonPlaceholder {
.Consensus .SmallRow .jdenticonPlaceholder {
width: 14px;
float: right;
}
.even {
.Consensus .even {
background-color: #333;
}
@@ -16,23 +16,47 @@ import './ConsensusBlock.css';
export namespace ConsensusBlock {
export interface Props {
authorities: Types.Authority[];
authoritySetId: Types.AuthoritySetId;
authoritySetId: Maybe<Types.AuthoritySetId>;
height: Types.BlockNumber;
firstInRow: boolean;
lastInRow: boolean;
compact: boolean;
measure: boolean;
consensusView: Types.ConsensusView;
changeBlocks: (first: boolean, boundsRect: BoundingRect) => void;
}
}
export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
public state = {
lastConsensusView: "",
};
public shouldComponentUpdate(nextProps: ConsensusBlock.Props): boolean {
if (this.props.authorities.length === 0 && nextProps.authorities.length === 0) {
return false;
}
const positionInfoChanged = this.props.firstInRow !== nextProps.firstInRow ||
this.props.lastInRow !== nextProps.lastInRow;
if (positionInfoChanged) {
return true;
}
const newConsensusInfo =
JSON.stringify(nextProps.consensusView) !== this.state.lastConsensusView;
if (newConsensusInfo) {
return true;
}
return false;
}
public render() {
this.state.lastConsensusView = JSON.stringify(this.props.consensusView);
const finalizedByWhom = this.props.authorities.filter(authority => this.isFinalized(authority));
const ratio = finalizedByWhom.length + '/' + this.props.authorities.length;
const tooltip = `${ratio} authorities finalized this block. Authority Set Id: ${this.props.authoritySetId}.`;
let titleFinal = <span>{ratio}</span>;
const majorityFinalized = finalizedByWhom.length / this.props.authorities.length >= 2/3;
@@ -40,48 +64,59 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
titleFinal = <span>FINAL</span>;
} else if (majorityFinalized && this.props.compact) {
const hash = this.getFinalizedHash(finalizedByWhom[0]);
titleFinal =
<Tooltip text={'Block hash: ' + hash ? String(hash) : ''} copy={true}>
<Jdenticon hash={hash ? String(hash) : ''} size={this.props.compact ? '14px' : '28px'}/>
</Tooltip>;
titleFinal = <Jdenticon hash={hash ? String(hash) : ''} size={this.props.compact ? '14px' : '28px'}/>
}
const handleOnResize = (contentRect: ContentRect) => {
this.props.changeBlocks(this.props.firstInRow, contentRect.bounds as BoundingRect);
};
return (<Measure bounds={true} onResize={handleOnResize}>{({ measureRef }) => (
<div
className={`BlockConsensusMatrice ${this.props.firstInRow ? 'firstInRow' : ''} ${this.props.lastInRow ? 'lastInRow' : ''}`}
const get = (measureRef: Maybe<(ref: Element | null) => void>) => {
return <div
className={
`BlockConsensusMatrice
${this.props.firstInRow ? 'firstInRow' : ''} ${this.props.lastInRow ? 'lastInRow' : ''}`
}
key={'block_' + this.props.height}>
<table ref={measureRef}>
<thead>
<tr className="Row">
{this.props.firstInRow && !this.props.compact ?
<th className="emptylegend">&nbsp;</th> : ''}
<th className="legend">
<Tooltip text={`Block number: ${this.props.height}`}>
{this.displayBlockNumber()}
</Tooltip>
</th>
<th className='finalizedInfo'>
<Tooltip text={tooltip}>{titleFinal}</Tooltip>
</th>
{this.props.authorities.map(authority =>
<th
className="matrixXLegend"
key={`${this.props.height}_matrice_x_${authority.Address}`}>
{this.getAuthorityContent(authority)}
</th>)}
</tr>
</thead>
<tbody>
<table ref={measureRef} key={'block_table_' + this.props.height}>
<thead key={'block_thead_' + this.props.height}>
<tr className="Row" key={'block_row_' + this.props.height}>
{this.props.firstInRow && !this.props.compact ?
<th className="emptylegend" key={'block_row_' + this.props.height + '_empty'}>&nbsp;</th> : null}
<th className="legend" key={'block_row_' + this.props.height + '_legend'}>
<Tooltip text={`Block number: ${this.props.height}`}>
{this.displayBlockNumber()}
</Tooltip>
</th>
<th className='finalizedInfo' key={'block_row_' + this.props.height + '_finalized_info'}>
{titleFinal}
</th>
{this.props.authorities.map(authority =>
<th
className="matrixXLegend"
key={`${this.props.height}_matrice_x_${authority.Address}`}>
{this.getAuthorityContent(authority)}
</th>)}
</tr>
</thead>
<tbody key={'block_row_' + this.props.height + '_tbody'}>
{this.props.authorities.map((authority, row) =>
this.renderMatriceRow(authority, this.props.authorities, row))}
</tbody>
</tbody>
</table>
</div>)}
</Measure>);
</div>
};
if (this.props.measure) {
return (
<Measure bounds={true} onResize={handleOnResize}>{({measureRef}) => (
get(measureRef)
)}
</Measure>
);
} else {
return (get(null));
}
}
private displayBlockNumber(): string {
@@ -111,88 +146,54 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
}
private renderMatriceRow(authority: Types.Authority, authorities: Types.Authority[], row: number): JSX.Element {
let finalizedInfo = <Tooltip text="No information available yet.">&nbsp;</Tooltip>;
let finalizedInfo = <span>&nbsp;</span>;
let finalizedHash;
if (authority.NodeId != null && this.isFinalized(authority)) {
const matrice = this.props.consensusView[authority.Address][authority.Address];
finalizedInfo = matrice.ImplicitFinalized ?
<Tooltip text={`${authority.Name} finalized this block in ${matrice.ImplicitPointer}`}>
<Icon className="implicit" src={finalizedIcon} alt="" />
</Tooltip>
:
<Tooltip text={`${authority.Name} finalized this block in this block`}>
<Icon className="explicit" src={finalizedIcon} alt="" />
</Tooltip>
<Icon className="implicit" src={finalizedIcon} alt="" /> :
<Icon className="explicit" src={finalizedIcon} alt="" />;
finalizedHash = matrice.FinalizedHash ?
<Tooltip text={`Block hash: ${matrice.FinalizedHash}`} copy={true}>
<Jdenticon hash={matrice.FinalizedHash} size="28px"/>
</Tooltip> : <div className="jdenticonPlaceholder">&nbsp;</div>;
<Jdenticon hash={matrice.FinalizedHash} size="28px"/> :
<div className="jdenticonPlaceholder">&nbsp;</div>;
}
const name = authority.Name ? <span>{authority.Name}</span> : <em>no name received yet</em>;
const firstName = this.props.firstInRow ? <td className="nameLegend">{name}</td> : '';
const name = authority.Name ?
<span>{authority.Name}</span> : <em>no data received from node</em>;
const firstName = this.props.firstInRow ?
<td key={"name_" + name} className="nameLegend">{name}</td> : '';
return <tr className="Row">
return <tr className="Row" key={'block_row_' + this.props.height + '_' + row}>
{firstName}
<td className="legend">{this.getAuthorityContent(authority)}</td>
<td className="finalizedInfo">{finalizedInfo}{finalizedHash}</td>
<td className="legend" key={'block_row_' + this.props.height + '_' + row + '_legend'}>{this.getAuthorityContent(authority)}</td>
<td className="finalizedInfo" key={'block_row_' + this.props.height + '_' + row + '_finalizedInfo'}>{finalizedInfo}{finalizedHash}</td>
{
authorities.map((columnNode, column) => {
const evenOdd = ((row % 2) + column) % 2 === 0 ? 'even' : 'odd';
return <td key={'matrice_' + authority.Address + '_' + columnNode.Address}
className={`matrice ${evenOdd}`}>{this.getMatriceContent(authority, columnNode)}</td>
return <td key={'matrice_' + this.props.height + '_' + row + '_' + authority.Address + '_' + columnNode.Address}
className={`matrice ${evenOdd}`}>{this.getCellContent(authority, columnNode)}</td>
})
}
</tr>;
}
private getAuthorityContent(authority: Types.Authority): JSX.Element {
return <div className="nodeContent">
<div className="nodeAddress">
<Tooltip text={authority.Address} copy={true}>
<Identicon account={authority.Address} size={this.props.compact ? 14 : 28} />
</Tooltip>
return <div className="nodeContent" key={'authority_' + this.props.height + '_' + authority.Address}>
<div className="nodeAddress" key={'authority_' + authority.Address}>
<Identicon account={authority.Address} size={this.props.compact ? 14 : 28} />
</div>
</div>;
}
private format(consensusDetail: Types.ConsensusDetail): string {
const txt = [];
if (consensusDetail.Prevote) {
txt.push('Prevote on this chain in this block');
} else if (consensusDetail.ImplicitPrevote) {
txt.push('Prevote on this chain in block ' + consensusDetail.ImplicitPointer);
}
if (consensusDetail.Precommit) {
txt.push('Precommit on this chain in this block');
} else if (consensusDetail.ImplicitPrecommit) {
txt.push('Precommit on this chain in block ' + consensusDetail.ImplicitPointer);
}
if (consensusDetail.Finalized && consensusDetail.ImplicitFinalized) {
txt.push('Finalized this chain in block ' + consensusDetail.ImplicitPointer);
} else if (consensusDetail.Finalized && !consensusDetail.ImplicitFinalized) {
txt.push('Finalized this chain in this block');
}
return txt.join(', '); // + JSON.stringify((consensusDetail));
}
private getMatriceContent(rowAuthority: Types.Authority, columnAuthority: Types.Authority) {
private getCellContent(rowAuthority: Types.Authority, columnAuthority: Types.Authority) {
const consensusInfo = this.props.consensusView &&
rowAuthority.Address &&
rowAuthority.Address in this.props.consensusView &&
columnAuthority.Address in this.props.consensusView[rowAuthority.Address] ?
this.props.consensusView[rowAuthority.Address][columnAuthority.Address] : null;
let tooltipText = consensusInfo ?
rowAuthority.Name + ' has seen this of ' + columnAuthority.Name + ': ' +
this.format(consensusInfo) : 'No information available yet.';
if (rowAuthority.Address === columnAuthority.Address) {
tooltipText = 'Self-referential.';
}
this.props.consensusView[rowAuthority.Address][columnAuthority.Address] : null;
const prevote = consensusInfo && consensusInfo.Prevote;
const implicitPrevote = consensusInfo && consensusInfo.ImplicitPrevote;
@@ -218,12 +219,9 @@ export class ConsensusBlock extends React.Component<ConsensusBlock.Props, {}> {
statPrecommit = <Icon src={checkIcon} className="explicit" alt="Precommit"/>;
}
const stat = [statPrevote, statPrecommit];
return <Tooltip text={tooltipText}>{stat}</Tooltip>
return <span key={"icons_pre"}>{statPrevote}{statPrecommit}</span>;
} else {
return <Tooltip text={tooltipText}>
<Icon src={hatchingIcon} className="hatching" alt=""/>
</Tooltip>
return <Icon src={hatchingIcon} className="hatching" alt=""/>;
}
}
+1 -1
View File
@@ -21,6 +21,6 @@ export class Icon extends React.Component<{}, Props> {
public render() {
const { alt, className, onClick, src } = this.props;
return <ReactSVG title={alt} className={`Icon ${ className || '' }`} path={src} onClick={onClick} />;
return <ReactSVG key={this.props.src} title={alt} className={`Icon ${ className || '' }`} path={src} onClick={onClick} />;
}
}
+4 -2
View File
@@ -194,8 +194,10 @@ export interface State {
best: Types.BlockNumber;
finalized: Types.BlockNumber;
consensusInfo: Types.ConsensusInfo;
displayConsensusLoadingScreen: boolean;
tabChanged: boolean;
authorities: Types.Address[];
authoritySetId: Types.AuthoritySetId;
authoritySetId: Maybe<Types.AuthoritySetId>;
sendFinality: boolean;
blockTimestamp: Types.Timestamp;
blockAverage: Maybe<Types.Milliseconds>;
@@ -208,4 +210,4 @@ export interface State {
}
export type Update = <K extends keyof State>(changes: Pick<State, K> | null) => Readonly<State>;
export type UpdateBound = <K extends keyof State>(changes: Pick<State, K> | null) => void;