Node Health warnings (#5951)

* Health endpoint.

* Asynchronous health endpoint.

* Configure time api URL via CLI.

* Tests for TimeChecker.

* Health indication on Status page.

* Adding status indication to tab titles.

* Add status to ParityBar.

* Fixing lints.

* Add health status on SyncWarning.

* Fix health URL for embed.

* Nicer messages.

* Fix tests.

* Fixing JS tests.

* NTP time sync (#5956)

* use NTP to check time drift

* update time module documentation

* replace time_api flag with ntp_server

* fix TimeChecker tests

* fix ntp-server flag usage

* hide status tooltip if there's no message to show

* remove TimeProvider trait

* use Cell in FakeNtp test trait

* share fetch client and ntp client cpu pool

* Add documentation to public method.

* Removing peer count from status.

* Remove unknown upgrade status.

* Send two time requests at the time.

* Revert "Send two time requests at the time."

This reverts commit f7b754b1155076a5a5d8fdafa022801fae324452.

* Defer reporting time synchronization issues.

* Fix tests.

* Fix linting.
This commit is contained in:
Tomasz Drwięga
2017-07-11 12:23:46 +02:00
committed by Gav Wood
parent 7fb46bff06
commit 4936e99f30
48 changed files with 1296 additions and 125 deletions

View File

@@ -94,6 +94,7 @@ class FrameSecureApi extends SecureApi {
const transport = window.secureTransport || new FakeTransport();
const uiUrl = transport.uiUrl || 'http://127.0.0.1:8180';
transport.uiUrlWithProtocol = uiUrl;
transport.uiUrl = uiUrl.replace('http://', '').replace('https://', '');
const api = new FrameSecureApi(transport);

View File

@@ -25,6 +25,10 @@ import { statusBlockNumber, statusCollection } from './statusActions';
const log = getLogger(LOG_KEYS.Signer);
let instance = null;
const STATUS_OK = 'ok';
const STATUS_WARN = 'needsAttention';
const STATUS_BAD = 'bad';
export default class Status {
_apiStatus = {};
_status = {};
@@ -195,13 +199,16 @@ export default class Status {
const statusPromises = [
this._api.eth.syncing(),
this._api.parity.netPeers()
this._api.parity.netPeers(),
this._fetchHealth()
];
return Promise
.all(statusPromises)
.then(([ syncing, netPeers ]) => {
const status = { netPeers, syncing };
.then(([ syncing, netPeers, health ]) => {
const status = { netPeers, syncing, health };
health.overall = this._overallStatus(health);
if (!isEqual(status, this._status)) {
this._store.dispatch(statusCollection(status));
@@ -216,6 +223,33 @@ export default class Status {
});
}
_overallStatus = (health) => {
const all = [health.peers, health.sync, health.time].filter(x => x);
const statuses = all.map(x => x.status);
const bad = statuses.find(x => x === STATUS_BAD);
const needsAttention = statuses.find(x => x === STATUS_WARN);
const message = all.map(x => x.message).filter(x => x);
if (all.length) {
return {
status: bad || needsAttention || STATUS_OK,
message
};
}
return {
status: STATUS_BAD,
message: ['Unable to fetch node health.']
};
}
_fetchHealth = () => {
// Support Parity-Extension.
const uiUrl = this._api.transport.uiUrlWithProtocol || '';
return fetch(`${uiUrl}/api/health`).then(res => res.json());
}
/**
* The data fetched here should not change
* unless Parity is restarted. They are thus

View File

@@ -18,11 +18,28 @@ import BigNumber from 'bignumber.js';
import { handleActions } from 'redux-actions';
const DEFAULT_NETCHAIN = '(unknown)';
const DEFAULT_STATUS = 'needsAttention';
const initialState = {
blockNumber: new BigNumber(0),
blockTimestamp: new Date(),
clientVersion: '',
gasLimit: new BigNumber(0),
health: {
peers: {
status: DEFAULT_STATUS
},
sync: {
status: DEFAULT_STATUS
},
time: {
status: DEFAULT_STATUS
},
overall: {
isReady: false,
status: DEFAULT_STATUS,
message: []
}
},
netChain: DEFAULT_NETCHAIN,
netPeers: {
active: new BigNumber(0),

View File

@@ -31,7 +31,8 @@ export default class Actionbar extends Component {
title: nodeOrStringProptype(),
buttons: PropTypes.array,
children: PropTypes.node,
className: PropTypes.string
className: PropTypes.string,
health: PropTypes.node
};
static defaultProps = {

View File

@@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './statusIndicator';

View File

@@ -0,0 +1,88 @@
/* Copyright 2015-2017 Parity Technologies (UK) Ltd.
/* This file is part of Parity.
/*
/* Parity is free software: you can redistribute it and/or modify
/* it under the terms of the GNU General Public License as published by
/* the Free Software Foundation, either version 3 of the License, or
/* (at your option) any later version.
/*
/* Parity is distributed in the hope that it will be useful,
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
/* GNU General Public License for more details.
/*
/* You should have received a copy of the GNU General Public License
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
*/
.status {
display: inline-block;
}
.radial,.signal {
display: inline-block;
margin: .2em;
width: 1em;
height: 1em;
}
.radial {
border-radius: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.5);
background-image: radial-gradient(ellipse at top, rgba(255, 255, 255, 0.38) 0%, rgba(255, 255, 255, 0) 100%);
&.ok {
background-color: #070;
}
&.bad {
background-color: #c00;
}
&.needsAttention {
background-color: #dc0;
}
}
.signal {
width: 2em;
width: calc(.9em + 5px);
text-transform: initial;
vertical-align: bottom;
margin-top: -1em;
> .bar {
display: inline-block;
border: 1px solid #444;
box-shadow: 0 0 1px rgba(0, 0, 0, 0.8);
width: .3em;
height: 1em;
opacity: 0.7;
background-color: rgba(0, 0, 0, 0.6);
vertical-align: bottom;
&.active {
opacity: 1.0;
background-image: linear-gradient(0, rgba(255, 255, 255, 0.38) 0%, rgba(255, 255, 255, 0) 100%);
}
&.bad {
height: .4em;
border-right: 0;
}
&.needsAttention {
height: .6em;
border-right: 0;
}
&.ok {
height: 1em;
}
}
&.bad > .bar.active {
background-color: #c00;
}
&.ok > .bar.active {
background-color: #080;
}
&.needsAttention > .bar.active {
background-color: #dc0;
}
}

View File

@@ -0,0 +1,70 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import ReactTooltip from 'react-tooltip';
import styles from './statusIndicator.css';
const statuses = ['bad', 'needsAttention', 'ok'];
export default class StatusIndicator extends Component {
static propTypes = {
type: PropTypes.oneOf(['radial', 'signal']),
id: PropTypes.string.isRequired,
status: PropTypes.oneOf(statuses).isRequired,
title: PropTypes.arrayOf(PropTypes.node),
tooltipPlacement: PropTypes.oneOf(['left', 'top', 'bottom', 'right'])
};
static defaultProps = {
type: 'signal',
title: []
};
render () {
const { id, status, title, type, tooltipPlacement } = this.props;
const tooltip = title.find(x => !x.isEmpty) ? (
<ReactTooltip id={ `status-${id}` }>
{ title.map(x => (<div key={ x }>{ x }</div>)) }
</ReactTooltip>
) : null;
return (
<span className={ styles.status }>
<span className={ `${styles[type]} ${styles[status]}` }
data-tip={ title.length }
data-for={ `status-${id}` }
data-place={ tooltipPlacement }
data-effect='solid'
>
{ type === 'signal' && statuses.map(this.renderBar) }
</span>
{tooltip}
</span>
);
}
renderBar = (signal) => {
const idx = statuses.indexOf(this.props.status);
const isActive = statuses.indexOf(signal) <= idx;
const activeClass = isActive ? styles.active : '';
return (
<span key={ signal } className={ `${styles.bar} ${styles[signal]} ${activeClass}` } />
);
}
}

View File

@@ -52,6 +52,7 @@ export SectionList from './SectionList';
export SelectionList from './SelectionList';
export ShortenedHash from './ShortenedHash';
export SignerIcon from './SignerIcon';
export StatusIndicator from './StatusIndicator';
export Tags from './Tags';
export Title from './Title';
export Tooltips, { Tooltip } from './Tooltips';

View File

@@ -43,6 +43,7 @@ class Accounts extends Component {
accountsInfo: PropTypes.object.isRequired,
availability: PropTypes.string.isRequired,
hasAccounts: PropTypes.bool.isRequired,
health: PropTypes.object.isRequired,
setVisibleAccounts: PropTypes.func.isRequired
}
@@ -496,12 +497,14 @@ class Accounts extends Component {
function mapStateToProps (state) {
const { accounts, accountsInfo, hasAccounts } = state.personal;
const { availability = 'unknown' } = state.nodeStatus.nodeKind || {};
const { health } = state.nodeStatus;
return {
accounts,
accountsInfo,
availability,
hasAccounts
hasAccounts,
health
};
}

View File

@@ -18,7 +18,7 @@ import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { BlockStatus } from '~/ui';
import { BlockStatus, StatusIndicator } from '~/ui';
import styles from './status.css';
@@ -28,11 +28,12 @@ class Status extends Component {
isTest: PropTypes.bool,
netChain: PropTypes.string,
netPeers: PropTypes.object,
health: PropTypes.object,
upgradeStore: PropTypes.object.isRequired
}
render () {
const { clientVersion, isTest, netChain, netPeers } = this.props;
const { clientVersion, isTest, netChain, netPeers, health } = this.props;
return (
<div className={ styles.status }>
@@ -44,13 +45,20 @@ class Status extends Component {
{ this.renderUpgradeButton() }
</div>
<div className={ styles.netinfo }>
<BlockStatus />
<div>
<StatusIndicator
type='signal'
id='application.status.health'
status={ health.overall.status }
title={ health.overall.message }
/>
</div>
<span title={ `${netPeers.connected.toFormat()}/${netPeers.max.toFormat()} peers` }>
<BlockStatus />
</span>
<div className={ `${styles.network} ${styles[isTest ? 'test' : 'live']}` }>
{ netChain }
</div>
<div className={ styles.peers }>
{ netPeers.connected.toFormat() }/{ netPeers.max.toFormat() } peers
</div>
</div>
</div>
);
@@ -102,14 +110,7 @@ class Status extends Component {
);
}
return (
<div>
<FormattedMessage
id='application.status.consensus.unknown'
defaultMessage='Upgrade status is unknown.'
/>
</div>
);
return;
}
renderUpgradeButton () {
@@ -136,10 +137,11 @@ class Status extends Component {
}
function mapStateToProps (state) {
const { clientVersion, netPeers, netChain, isTest } = state.nodeStatus;
const { clientVersion, health, netPeers, netChain, isTest } = state.nodeStatus;
return {
clientVersion,
health,
netPeers,
netChain,
isTest

View File

@@ -81,6 +81,16 @@
white-space: nowrap;
}
.indicatorTab {
font-size: 1.5rem;
flex: 0;
}
.indicator {
padding: 20px 12px 0;
opacity: 0.8;
}
.first {
margin-left: -24px;
}

View File

@@ -21,7 +21,7 @@ import { Link } from 'react-router';
import { Toolbar, ToolbarGroup } from 'material-ui/Toolbar';
import { isEqual } from 'lodash';
import { Tooltip } from '~/ui';
import { Tooltip, StatusIndicator } from '~/ui';
import Tab from './Tab';
import styles from './tabBar.css';
@@ -33,6 +33,7 @@ class TabBar extends Component {
static propTypes = {
pending: PropTypes.array,
health: PropTypes.object.isRequired,
views: PropTypes.array.isRequired
};
@@ -41,12 +42,29 @@ class TabBar extends Component {
};
render () {
const { health } = this.props;
return (
<Toolbar className={ styles.toolbar }>
<ToolbarGroup className={ styles.first }>
<div />
</ToolbarGroup>
<div className={ styles.tabs }>
<Link
activeClassName={ styles.tabactive }
className={ `${styles.tabLink} ${styles.indicatorTab}` }
key='status'
to='/status'
>
<div className={ styles.indicator }>
<StatusIndicator
type='signal'
id='topbar.health'
status={ health.overall.status }
title={ health.overall.message }
/>
</div>
</Link>
{ this.renderTabItems() }
<Tooltip
className={ styles.tabbarTooltip }
@@ -101,6 +119,7 @@ function mapStateToProps (initState) {
return (state) => {
const { availability = 'unknown' } = state.nodeStatus.nodeKind || {};
const { views } = state.settings;
const { health } = state.nodeStatus;
const viewIds = Object
.keys(views)
@@ -114,7 +133,7 @@ function mapStateToProps (initState) {
});
if (isEqual(viewIds, filteredViewIds)) {
return { views: filteredViews };
return { views: filteredViews, health };
}
filteredViewIds = viewIds;
@@ -123,7 +142,7 @@ function mapStateToProps (initState) {
id
}));
return { views: filteredViews };
return { views: filteredViews, health };
};
}

View File

@@ -37,6 +37,12 @@ function createStore () {
nodeStatus: {
nodeKind: {
'availability': 'personal'
},
health: {
overall: {
status: 'ok',
message: []
}
}
}
};

View File

@@ -24,7 +24,7 @@ import { connect } from 'react-redux';
import store from 'store';
import imagesEthcoreBlock from '~/../assets/images/parity-logo-white-no-text.svg';
import { AccountCard, Badge, Button, ContainerTitle, IdentityIcon, ParityBackground, SelectionList } from '~/ui';
import { AccountCard, Badge, Button, ContainerTitle, IdentityIcon, ParityBackground, SelectionList, StatusIndicator } from '~/ui';
import { CancelIcon, FingerprintIcon } from '~/ui/Icons';
import DappsStore from '~/views/Dapps/dappsStore';
import { Embedded as Signer } from '~/views/Signer';
@@ -50,7 +50,8 @@ class ParityBar extends Component {
static propTypes = {
dapp: PropTypes.bool,
externalLink: PropTypes.string,
pending: PropTypes.array
pending: PropTypes.array,
health: PropTypes.object
};
state = {
@@ -210,7 +211,7 @@ class ParityBar extends Component {
}
renderBar () {
const { dapp } = this.props;
const { dapp, health } = this.props;
if (!dapp) {
return null;
@@ -218,6 +219,13 @@ class ParityBar extends Component {
return (
<div className={ styles.cornercolor }>
<StatusIndicator
type='signal'
id='paritybar.health'
status={ health.overall.status }
title={ health.overall.message }
tooltipPlacement='right'
/>
<Button
className={ styles.iconButton }
icon={
@@ -699,9 +707,11 @@ class ParityBar extends Component {
function mapStateToProps (state) {
const { pending } = state.signer;
const { health } = state.nodeStatus;
return {
pending
pending,
health
};
}

View File

@@ -37,6 +37,14 @@ function createRedux (state = {}) {
},
signer: {
pending: []
},
nodeStatus: {
health: {
overall: {
status: 'ok',
message: []
}
}
}
}, state)
};

View File

@@ -17,7 +17,7 @@
import React from 'react';
import imagesEthcoreBlock from '~/../assets/images/parity-logo-white-no-text.svg';
import { AccountsIcon, AddressesIcon, AppsIcon, ContactsIcon, FingerprintIcon, SettingsIcon, StatusIcon } from '~/ui/Icons';
import { AccountsIcon, AddressesIcon, AppsIcon, ContactsIcon, FingerprintIcon, SettingsIcon } from '~/ui/Icons';
import styles from './views.css';
@@ -65,14 +65,6 @@ const defaultViews = {
value: 'contract'
},
status: {
active: false,
onlyPersonal: true,
icon: <StatusIcon />,
route: '/status',
value: 'status'
},
signer: {
active: true,
fixed: true,

View File

@@ -113,17 +113,6 @@ class Views extends Component {
/>
)
}
{
this.renderView('status',
<FormattedMessage
id='settings.views.status.label'
/>,
<FormattedMessage
id='settings.views.status.description'
defaultMessage='See how the Parity node is performing in terms of connections to the network, logs from the actual running instance and details of mining (if enabled and configured).'
/>
)
}
{
this.renderView('signer',
<FormattedMessage

View File

@@ -0,0 +1,152 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Container, ContainerTitle, StatusIndicator } from '~/ui';
import grid from '../NodeStatus/nodeStatus.css';
const HealthItem = (props) => {
const status = props.item.status || 'needsAttention';
return (
<div>
<h3>
<StatusIndicator
id={ props.id }
title={ [
(<div>{ props.item.message }</div>)
] }
status={ status }
/>
{ props.title }
<small>&nbsp;({ props.details })</small>
</h3>
<p>
{ status !== 'ok' ? props.item.message : '' }
</p>
</div>
);
};
HealthItem.propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.node.isRequired,
details: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
]).isRequired,
item: PropTypes.object.isRequired
};
class Health extends Component {
static contextTypes = {
api: PropTypes.object.isRequired
};
static propTypes = {
peers: PropTypes.object.isRequired,
sync: PropTypes.object.isRequired,
time: PropTypes.object.isRequired
};
state = {};
render () {
const { peers, sync, time } = this.props;
const [yes, no] = [(
<FormattedMessage
id='status.health.yes'
defaultMessage='yes'
/>
), (
<FormattedMessage
id='status.health.no'
defaultMessage='no'
/>
)];
return (
<Container>
<ContainerTitle
title={
<div>
<FormattedMessage
id='status.health.title'
defaultMessage='Node Health'
/>
</div>
}
/>
<div className={ grid.container }>
<div className={ grid.row }>
<div className={ grid.col4 }>
<HealthItem
id='status.health.sync'
title={
<FormattedMessage
id='status.health.sync'
defaultMessage='Chain Synchronized'
/>
}
details={ !sync.details ? yes : no }
item={ sync }
/>
</div>
<div className={ grid.col4 }>
<HealthItem
id='status.health.peers'
title={
<FormattedMessage
id='status.health.peers'
defaultMessage='Connected Peers'
/>
}
details={ (peers.details || []).join('/') }
item={ peers }
/>
</div>
<div className={ grid.col4 }>
<HealthItem
id='status.health.time'
title={
<FormattedMessage
id='status.health.time'
defaultMessage='Time Synchronized'
/>
}
details={ `${time.details || 0} ms` }
item={ time }
/>
</div>
</div>
</div>
</Container>
);
}
}
function mapStateToProps (state) {
return state.nodeStatus.health;
}
export default connect(
mapStateToProps,
null
)(Health);

View File

@@ -0,0 +1,17 @@
// Copyright 2015-2017 Parity Technologies (UK) Ltd.
// This file is part of Parity.
// Parity is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Parity is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
export default from './health';

View File

@@ -44,7 +44,7 @@
}
.col,
.col3, .col4_5, .col6, .col12 {
.col3, .col4, .col4_5, .col6, .col12 {
float: left;
padding: 0 1em;
box-sizing: border-box;
@@ -57,6 +57,13 @@
width: calc(100% / 12 * 3);
}
.col4 {
width: 33.3%;
width: -webkit-calc(100% / 12 * 4);
width: -moz-calc(100% / 12 * 4);
width: calc(100% / 12 * 4);
}
.col4_5 {
width: 37.5%;
width: -webkit-calc(100% / 12 * 4.5);

View File

@@ -20,6 +20,7 @@ import { FormattedMessage } from 'react-intl';
import { Page } from '~/ui';
import Debug from './Debug';
import Health from './Health';
import Peers from './Peers';
import NodeStatus from './NodeStatus';
@@ -35,6 +36,7 @@ export default () => (
}
>
<div className={ styles.body }>
<Health />
<NodeStatus />
<Peers />
<Debug />

View File

@@ -58,3 +58,7 @@
margin: 0.5em 0;
}
}
.status {
font-size: 4rem;
}

View File

@@ -20,7 +20,7 @@ import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import store from 'store';
import { Button } from '~/ui';
import { Button, StatusIndicator } from '~/ui';
import styles from './syncWarning.css';
@@ -38,7 +38,8 @@ export const showSyncWarning = () => {
class SyncWarning extends Component {
static propTypes = {
isSyncing: PropTypes.bool
isOk: PropTypes.bool.isRequired,
health: PropTypes.object.isRequired
};
state = {
@@ -47,10 +48,10 @@ class SyncWarning extends Component {
};
render () {
const { isSyncing } = this.props;
const { isOk, health } = this.props;
const { dontShowAgain, show } = this.state;
if (!isSyncing || isSyncing === null || !show) {
if (isOk || !show) {
return null;
}
@@ -59,18 +60,19 @@ class SyncWarning extends Component {
<div className={ styles.overlay } />
<div className={ styles.modal }>
<div className={ styles.body }>
<FormattedMessage
id='syncWarning.message.line1'
defaultMessage={ `
Your Parity node is still syncing to the chain.
` }
/>
<FormattedMessage
id='syncWarning.message.line2'
defaultMessage={ `
Some of the shown information might be out-of-date.
` }
/>
<div className={ styles.status }>
<StatusIndicator
type='signal'
id='healthWarning.indicator'
status={ health.overall.status }
/>
</div>
{
health.overall.message.map(message => (
<p key={ message }>{ message }</p>
))
}
<div className={ styles.button }>
<Checkbox
@@ -113,14 +115,13 @@ class SyncWarning extends Component {
}
function mapStateToProps (state) {
const { syncing } = state.nodeStatus;
// syncing could be an Object, false, or null
const isSyncing = syncing
? true
: syncing;
const { health } = state.nodeStatus;
const isNotAvailableYet = health.overall.isReady;
const isOk = isNotAvailableYet || health.overall.status === 'ok';
return {
isSyncing
isOk,
health
};
}

View File

@@ -26,7 +26,12 @@ function createRedux (syncing = null) {
getState: () => {
return {
nodeStatus: {
syncing
health: {
overall: {
status: syncing ? 'needsAttention' : 'ok',
message: []
}
}
}
};
}