diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 5704ccdd8..534391783 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -468,6 +468,13 @@ export function receiveNodeDetails(details) { export function receiveNodesDelta(delta) { return (dispatch, getState) => { + // + // allow css-animation to run smoothly by scheduling it to run on the + // next tick after any potentially expensive canvas re-draws have been + // completed. + // + setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); + if (delta.add || delta.update || delta.remove) { const state = getState(); if (state.get('updatePausedAt') !== null) { diff --git a/client/app/scripts/charts/nodes-error.js b/client/app/scripts/charts/nodes-error.js index 877fa2517..e6204bfb1 100644 --- a/client/app/scripts/charts/nodes-error.js +++ b/client/app/scripts/charts/nodes-error.js @@ -1,16 +1,19 @@ import React from 'react'; +import classnames from 'classnames'; -export default function NodesError({children, faIconClass, hidden}) { - let classNames = 'nodes-chart-error'; - if (hidden) { - classNames += ' hide'; - } +export default function NodesError({children, faIconClass, hidden, + mainClassName = 'nodes-chart-error'}) { + const className = classnames(mainClassName, { + hide: hidden + }); const iconClassName = `fa ${faIconClass}`; return ( -
-
- +
+
+
+ +
{children}
diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index 97145ff7c..ae61fff59 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -9,6 +9,7 @@ import { fromJS } from 'immutable'; import debug from 'debug'; const log = debug('scope:debug-panel'); +import ActionTypes from '../constants/action-types'; import { receiveNodesDelta } from '../actions/app-actions'; import { getNodeColor, getNodeColorDark, text2degree } from '../utils/color-utils'; @@ -111,11 +112,13 @@ function stopPerf() { Perf.printWasted(measurements); } + function startPerf(delay) { Perf.start(); setTimeout(stopPerf, delay * 1000); } + export function showingDebugToolbar() { return (('debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar)) || location.pathname.indexOf('debug') > -1); @@ -134,11 +137,23 @@ function enableLog(ns) { window.location.reload(); } + function disableLog() { debug.disable(); window.location.reload(); } + +function setAppState(fn) { + return (dispatch) => { + dispatch({ + type: ActionTypes.DEBUG_TOOLBAR_INTERFERING, + fn + }); + }; +} + + class DebugToolbar extends React.Component { constructor(props, context) { @@ -162,6 +177,10 @@ class DebugToolbar extends React.Component { }); } + setLoading(loading) { + this.props.setAppState(state => state.set('topologiesLoaded', !loading)); + } + addNodes(n, prefix = 'zing') { const ns = this.props.nodes; const nodeNames = ns.keySeq().toJS(); @@ -243,6 +262,12 @@ class DebugToolbar extends React.Component { ))} +
+ + + +
+
@@ -254,6 +279,7 @@ class DebugToolbar extends React.Component { } } + function mapStateToProps(state) { return { nodes: state.get('nodes'), @@ -261,6 +287,8 @@ function mapStateToProps(state) { }; } + export default connect( - mapStateToProps + mapStateToProps, + {setAppState} )(DebugToolbar); diff --git a/client/app/scripts/components/loading.js b/client/app/scripts/components/loading.js new file mode 100644 index 000000000..2696f46b1 --- /dev/null +++ b/client/app/scripts/components/loading.js @@ -0,0 +1,58 @@ +import React from 'react'; +import _ from 'lodash'; + +import { findTopologyById } from '../utils/topology-utils'; +import NodesError from '../charts/nodes-error'; + + +const LOADING_TEMPLATES = [ + 'Loading THINGS', + 'Verifying THINGS', + 'Fetching THINGS', + 'Processing THINGS', + 'Reticulating THINGS', + 'Locating THINGS', + 'Optimizing THINGS', + 'Transporting THINGS', +]; + + +export function getNodeType(topology, topologies) { + if (!topology || topologies.size === 0) { + return ''; + } + let name = topology.get('name'); + if (topology.get('parentId')) { + const parentTopology = findTopologyById(topologies, topology.get('parentId')); + name = parentTopology.get('name'); + } + return name.toLowerCase(); +} + + +function renderTemplate(nodeType, template) { + return template.replace('THINGS', nodeType); +} + + +export class Loading extends React.Component { + + constructor(props, context) { + super(props, context); + + this.state = { + template: _.sample(LOADING_TEMPLATES) + }; + } + + render() { + const { itemType, show } = this.props; + const message = renderTemplate(itemType, this.state.template); + return ( + + ); + } + +} diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index d2ce010be..38ae981c2 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -3,12 +3,15 @@ import { connect } from 'react-redux'; import NodesChart from '../charts/nodes-chart'; import NodesError from '../charts/nodes-error'; +import { DelayedShow } from '../utils/delayed-show'; +import { Loading, getNodeType } from './loading'; import { isTopologyEmpty } from '../utils/topology-utils'; const navbarHeight = 160; const marginTop = 0; const detailsWidth = 450; + /** * dynamic coords precision based on topology size */ @@ -34,7 +37,7 @@ class Nodes extends React.Component { this.state = { width: window.innerWidth, - height: window.innerHeight - navbarHeight - marginTop + height: window.innerHeight - navbarHeight - marginTop, }; } @@ -63,14 +66,20 @@ class Nodes extends React.Component { } render() { - const { nodes, selectedNodeId, topologyEmpty } = this.props; + const { nodes, selectedNodeId, topologyEmpty, topologiesLoaded, nodesLoaded, topologies, + topology } = this.props; const layoutPrecision = getLayoutPrecision(nodes.size); const hasSelectedNode = selectedNodeId && nodes.has(selectedNodeId); - const errorEmpty = this.renderEmptyTopologyError(topologyEmpty); return (
- {topologyEmpty && errorEmpty} + + + + + {this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} details nodes: makeOrderedMap(), // nodeId -> node + nodesLoaded: false, // nodes cache, infrequently updated, used for search nodesByTopology: makeMap(), // topologyId -> nodes pinnedMetric: null, @@ -61,7 +62,7 @@ export const initialState = makeMap({ updatePausedAt: null, // Date version: '...', versionUpdate: null, - websocketClosed: true, + websocketClosed: false, exportingGraph: false }); @@ -480,6 +481,10 @@ export function rootReducer(state = initialState, action) { return state; } + case ActionTypes.SET_RECEIVED_NODES_DELTA: { + return state.set('nodesLoaded', true); + } + case ActionTypes.RECEIVE_NODES_DELTA: { const emptyMessage = !action.delta.add && !action.delta.remove && !action.delta.update; @@ -656,6 +661,10 @@ export function rootReducer(state = initialState, action) { return applyPinnedSearches(state); } + case ActionTypes.DEBUG_TOOLBAR_INTERFERING: { + return action.fn(state); + } + default: { return state; } diff --git a/client/app/scripts/utils/delayed-show.js b/client/app/scripts/utils/delayed-show.js new file mode 100644 index 000000000..fb4df720e --- /dev/null +++ b/client/app/scripts/utils/delayed-show.js @@ -0,0 +1,61 @@ +import React from 'react'; + + +export class DelayedShow extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + show: false + }; + } + + componentWillMount() { + if (this.props.show) { + this.scheduleShow(); + } + } + + componentWillUnmount() { + this.cancelShow(); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.show === this.props.show) { + return; + } + + if (nextProps.show) { + this.scheduleShow(); + } else { + this.cancelShow(); + this.setState({ show: false }); + } + } + + scheduleShow() { + this.showTimeout = setTimeout(() => this.setState({ show: true }), this.props.delay); + } + + cancelShow() { + clearTimeout(this.showTimeout); + } + + render() { + const { children } = this.props; + const { show } = this.state; + const style = { + opacity: show ? 1 : 0, + transition: 'opacity 0.5s ease-in-out', + }; + return ( +
+ {children} +
+ ); + } +} + + +DelayedShow.defaultProps = { + delay: 1000 +}; diff --git a/client/app/styles/main.less b/client/app/styles/main.less index acf5aa467..f1c19adce 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -296,14 +296,16 @@ h2 { .nodes-chart { - &-error { + &-error, &-loading { .hideable; position: absolute; left: 50%; top: 50%; - transform: translate(-50%, -50%); + margin-left: -16.5%; + margin-top: -275px; color: @text-secondary-color; width: 33%; + height: 550px; .heading { font-size: 125%; @@ -316,6 +318,14 @@ h2 { } } + &-loading &-error-icon-container { + animation: blinking 2.0s infinite @base-ease; + } + + &-loading { + text-align: center; + } + svg { .hideable; position: absolute;