diff --git a/app/api_report.go b/app/api_report.go index 05c9f0a9a6..6dfbd153dd 100644 --- a/app/api_report.go +++ b/app/api_report.go @@ -13,7 +13,7 @@ import ( // Raw report handler func makeRawReportHandler(rep Reporter) CtxHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request) { - report, err := rep.Report(ctx) + report, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return @@ -32,7 +32,7 @@ type probeDesc struct { // Probe handler func makeProbeHandler(rep Reporter) CtxHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request) { - rpt, err := rep.Report(ctx) + rpt, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/app/api_topologies.go b/app/api_topologies.go index 4f94d8823a..e2e7793608 100644 --- a/app/api_topologies.go +++ b/app/api_topologies.go @@ -7,6 +7,7 @@ import ( "sort" "strings" "sync" + "time" log "github.com/Sirupsen/logrus" "github.com/gorilla/mux" @@ -476,7 +477,7 @@ func (r *Registry) walk(f func(APITopologyDesc)) { // makeTopologyList returns a handler that yields an APITopologyList. func (r *Registry) makeTopologyList(rep Reporter) CtxHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, req *http.Request) { - report, err := rep.Report(ctx) + report, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return @@ -568,7 +569,7 @@ func (r *Registry) captureRenderer(rep Reporter, f rendererHandler) CtxHandlerFu http.NotFound(w, req) return } - rpt, err := rep.Report(ctx) + rpt, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/app/api_topology.go b/app/api_topology.go index 36d46ae164..e0c8e2f5a5 100644 --- a/app/api_topology.go +++ b/app/api_topology.go @@ -93,16 +93,33 @@ func handleWebsocket( }(conn) var ( - previousTopo detailed.NodeSummaries - tick = time.Tick(loop) - wait = make(chan struct{}, 1) - topologyID = mux.Vars(r)["topology"] + previousTopo detailed.NodeSummaries + tick = time.Tick(loop) + wait = make(chan struct{}, 1) + topologyID = mux.Vars(r)["topology"] + channelOpenedAt = time.Now() + // By default we will always be reporting the most recent state. + startReportingAt = time.Now() ) + + // If the timestamp is provided explicitly by the UI, we start reporting from there. + if timestampStr := r.Form.Get("timestamp"); timestampStr != "" { + startReportingAt, _ = time.Parse(time.RFC3339, timestampStr) + } + rep.WaitOn(ctx, wait) defer rep.UnWait(ctx, wait) for { - report, err := rep.Report(ctx) + // We measure how much time has passed since the channel was opened + // and add it to the initial report timestamp to get the timestamp + // of the snapshot we want to report right now. + // NOTE: Multiplying `timestampDelta` by a constant factor here + // would have an effect of fast-forward, which is something we + // might be interested in implementing in the future. + timestampDelta := time.Since(channelOpenedAt) + reportTimestamp := startReportingAt.Add(timestampDelta) + report, err := rep.Report(ctx, reportTimestamp) if err != nil { log.Errorf("Error generating report: %v", err) return diff --git a/app/benchmark_internal_test.go b/app/benchmark_internal_test.go index 0b75c24d65..493244e295 100644 --- a/app/benchmark_internal_test.go +++ b/app/benchmark_internal_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "testing" + "time" "golang.org/x/net/context" @@ -27,7 +28,7 @@ func loadReport() (report.Report, error) { return fixture.Report, err } - return c.Report(context.Background()) + return c.Report(context.Background(), time.Now()) } func BenchmarkTopologyList(b *testing.B) { diff --git a/app/collector.go b/app/collector.go index 659fe242ad..03319cdf00 100644 --- a/app/collector.go +++ b/app/collector.go @@ -28,7 +28,7 @@ const reportQuantisationInterval = 3 * time.Second // Reporter is something that can produce reports on demand. It's a convenient // interface for parts of the app, and several experimental components. type Reporter interface { - Report(context.Context) (report.Report, error) + Report(context.Context, time.Time) (report.Report, error) WaitOn(context.Context, chan struct{}) UnWait(context.Context, chan struct{}) } @@ -118,14 +118,14 @@ func (c *collector) Add(_ context.Context, rpt report.Report, _ []byte) error { // Report returns a merged report over all added reports. It implements // Reporter. -func (c *collector) Report(_ context.Context) (report.Report, error) { +func (c *collector) Report(_ context.Context, timestamp time.Time) (report.Report, error) { c.mtx.Lock() defer c.mtx.Unlock() // If the oldest report is still within range, // and there is a cached report, return that. if c.cached != nil && len(c.reports) > 0 { - oldest := mtime.Now().Add(-c.window) + oldest := timestamp.Add(-c.window) if c.timestamps[0].After(oldest) { return *c.cached, nil } @@ -191,7 +191,9 @@ type StaticCollector report.Report // Report returns a merged report over all added reports. It implements // Reporter. -func (c StaticCollector) Report(context.Context) (report.Report, error) { return report.Report(c), nil } +func (c StaticCollector) Report(context.Context, time.Time) (report.Report, error) { + return report.Report(c), nil +} // Add adds a report to the collector's internal state. It implements Adder. func (c StaticCollector) Add(context.Context, report.Report, []byte) error { return nil } diff --git a/app/collector_test.go b/app/collector_test.go index 6491cf740f..d8f9d26dc0 100644 --- a/app/collector_test.go +++ b/app/collector_test.go @@ -18,13 +18,17 @@ func TestCollector(t *testing.T) { window := 10 * time.Second c := app.NewCollector(window) + now := time.Now() + mtime.NowForce(now) + defer mtime.NowReset() + r1 := report.MakeReport() r1.Endpoint.AddNode(report.MakeNode("foo")) r2 := report.MakeReport() r2.Endpoint.AddNode(report.MakeNode("foo")) - have, err := c.Report(ctx) + have, err := c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } @@ -33,7 +37,7 @@ func TestCollector(t *testing.T) { } c.Add(ctx, r1, nil) - have, err = c.Report(ctx) + have, err = c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } @@ -41,17 +45,30 @@ func TestCollector(t *testing.T) { t.Error(test.Diff(want, have)) } + timeBefore := mtime.Now() + mtime.NowForce(now.Add(time.Second)) + c.Add(ctx, r2, nil) merged := report.MakeReport() merged = merged.Merge(r1) merged = merged.Merge(r2) - have, err = c.Report(ctx) + have, err = c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } if want := merged; !reflect.DeepEqual(want, have) { t.Error(test.Diff(want, have)) } + + // Since the timestamp given is before r2 was added, + // it shouldn't be included in the final report. + have, err = c.Report(ctx, timeBefore) + if err != nil { + t.Error(err) + } + if want := r1; !reflect.DeepEqual(want, have) { + t.Error(test.Diff(want, have)) + } } func TestCollectorExpire(t *testing.T) { @@ -64,7 +81,7 @@ func TestCollectorExpire(t *testing.T) { c := app.NewCollector(window) // 1st check the collector is empty - have, err := c.Report(ctx) + have, err := c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } @@ -76,7 +93,7 @@ func TestCollectorExpire(t *testing.T) { r1 := report.MakeReport() r1.Endpoint.AddNode(report.MakeNode("foo")) c.Add(ctx, r1, nil) - have, err = c.Report(ctx) + have, err = c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } @@ -86,7 +103,7 @@ func TestCollectorExpire(t *testing.T) { // Finally move time forward to expire the report mtime.NowForce(now.Add(window)) - have, err = c.Report(ctx) + have, err = c.Report(ctx, mtime.Now()) if err != nil { t.Error(err) } diff --git a/app/multitenant/aws_collector.go b/app/multitenant/aws_collector.go index d0810368bc..d7288d1bb9 100644 --- a/app/multitenant/aws_collector.go +++ b/app/multitenant/aws_collector.go @@ -297,12 +297,13 @@ func (c *awsCollector) getReports(ctx context.Context, reportKeys []string) ([]r return reports, nil } -func (c *awsCollector) Report(ctx context.Context) (report.Report, error) { +func (c *awsCollector) Report(ctx context.Context, timestamp time.Time) (report.Report, error) { var ( - now = time.Now() - start = now.Add(-c.window) - rowStart, rowEnd = start.UnixNano() / time.Hour.Nanoseconds(), now.UnixNano() / time.Hour.Nanoseconds() - userid, err = c.userIDer(ctx) + end = timestamp + start = end.Add(-c.window) + rowStart = start.UnixNano() / time.Hour.Nanoseconds() + rowEnd = end.UnixNano() / time.Hour.Nanoseconds() + userid, err = c.userIDer(ctx) ) if err != nil { return report.MakeReport(), err @@ -311,12 +312,12 @@ func (c *awsCollector) Report(ctx context.Context) (report.Report, error) { // Queries will only every span 2 rows max. var reportKeys []string if rowStart != rowEnd { - reportKeys1, err := c.getReportKeys(ctx, userid, rowStart, start, now) + reportKeys1, err := c.getReportKeys(ctx, userid, rowStart, start, end) if err != nil { return report.MakeReport(), err } - reportKeys2, err := c.getReportKeys(ctx, userid, rowEnd, start, now) + reportKeys2, err := c.getReportKeys(ctx, userid, rowEnd, start, end) if err != nil { return report.MakeReport(), err } @@ -324,12 +325,12 @@ func (c *awsCollector) Report(ctx context.Context) (report.Report, error) { reportKeys = append(reportKeys, reportKeys1...) reportKeys = append(reportKeys, reportKeys2...) } else { - if reportKeys, err = c.getReportKeys(ctx, userid, rowEnd, start, now); err != nil { + if reportKeys, err = c.getReportKeys(ctx, userid, rowEnd, start, end); err != nil { return report.MakeReport(), err } } - log.Debugf("Fetching %d reports from %v to %v", len(reportKeys), start, now) + log.Debugf("Fetching %d reports from %v to %v", len(reportKeys), start, end) reports, err := c.getReports(ctx, reportKeys) if err != nil { return report.MakeReport(), err diff --git a/app/router.go b/app/router.go index 0bfbbd4d5b..90480a0944 100644 --- a/app/router.go +++ b/app/router.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "sync" + "time" "github.com/PuerkitoBio/ghost/handlers" log "github.com/Sirupsen/logrus" @@ -179,7 +180,7 @@ func NewVersion(version, downloadURL string) { func apiHandler(rep Reporter) CtxHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request) { - report, err := rep.Report(ctx) + report, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/app/router_test.go b/app/router_test.go index 57a68e3c8f..4dfd244d0d 100644 --- a/app/router_test.go +++ b/app/router_test.go @@ -74,7 +74,7 @@ func TestReportPostHandler(t *testing.T) { } ctx := context.Background() - report, err := c.Report(ctx) + report, err := c.Report(ctx, time.Now()) if err != nil { t.Error(err) } diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d3c5c767f9..83465b63c5 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,28 +1,23 @@ import debug from 'debug'; -import find from 'lodash/find'; +import { find } from 'lodash'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; -import { - bufferDeltaUpdate, - resumeUpdate, - resetUpdateBuffer, -} from '../utils/update-buffer-utils'; import { doControlRequest, getAllNodes, getResourceViewNodesSnapshot, - getNodesDelta, + updateWebsocketChannel, getNodeDetails, getTopologies, deletePipe, stopPolling, teardownWebsockets, } from '../utils/web-api-utils'; -import { getCurrentTopologyUrl } from '../utils/topology-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; +import { isPausedSelector } from '../selectors/time-travel'; import { availableMetricTypesSelector, nextPinnedMetricTypeSelector, @@ -34,15 +29,21 @@ import { isResourceViewModeSelector, resourceViewAvailableSelector, } from '../selectors/topology'; + +import { NODES_DELTA_BUFFER_SIZE_LIMIT } from '../constants/limits'; +import { NODES_DELTA_BUFFER_FEED_INTERVAL } from '../constants/timer'; import { GRAPH_VIEW_MODE, TABLE_VIEW_MODE, RESOURCE_VIEW_MODE, - } from '../constants/naming'; +} from '../constants/naming'; const log = debug('scope:app-actions'); +// TODO: This shouldn't be exposed here as a global variable. +let nodesDeltaBufferUpdateTimer = null; + export function showHelp() { return { type: ActionTypes.SHOW_HELP }; } @@ -75,6 +76,11 @@ export function sortOrderChanged(sortedBy, sortedDesc) { }; } +function resetNodesDeltaBuffer() { + clearInterval(nodesDeltaBufferUpdateTimer); + return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; +} + // // Networks @@ -211,14 +217,10 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { }); updateRoute(getState); // update all request workers with new options - resetUpdateBuffer(); + dispatch(resetNodesDeltaBuffer()); const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - dispatch - ); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -387,15 +389,6 @@ export function clickRelative(nodeId, topologyId, label, origin) { }; } -export function clickResumeUpdate() { - return (dispatch, getState) => { - dispatch({ - type: ActionTypes.CLICK_RESUME_UPDATE - }); - resumeUpdate(getState); - }; -} - function updateTopology(dispatch, getState) { const state = getState(); // If we're in the resource view, get the snapshot of all the relevant node topologies. @@ -404,15 +397,11 @@ function updateTopology(dispatch, getState) { } updateRoute(getState); // update all request workers with new options - resetUpdateBuffer(); + dispatch(resetNodesDeltaBuffer()); // NOTE: This is currently not needed for our static resource // view, but we'll need it here later and it's simpler to just // keep it than to redo the nodes delta updating logic. - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - dispatch - ); + updateWebsocketChannel(state, dispatch); } export function clickShowTopologyForNode(topologyId, nodeId) { @@ -436,6 +425,24 @@ export function clickTopology(topologyId) { }; } +export function startWebsocketTransitionLoader() { + return { + type: ActionTypes.START_WEBSOCKET_TRANSITION_LOADER, + }; +} + +export function websocketQueryInPast(millisecondsInPast) { + return (dispatch, getServiceState) => { + dispatch({ + type: ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST, + millisecondsInPast, + }); + const scopeState = getServiceState().scope; + updateWebsocketChannel(scopeState, dispatch); + dispatch(resetNodesDeltaBuffer()); + }; +} + export function cacheZoomState(zoomState) { return { type: ActionTypes.CACHE_ZOOM_STATE, @@ -590,10 +597,21 @@ export function receiveNodesDelta(delta) { // setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); - if (delta.add || delta.update || delta.remove) { - const state = getState(); - if (state.get('updatePausedAt') !== null) { - bufferDeltaUpdate(delta); + // TODO: This way of getting the Scope state is a bit hacky, so try to replace + // it with something better. The problem is that all the actions that are called + // from the components wrapped in have a global Service state + // returned by getState(). Since method is called from both contexts, getState() + // will sometimes return Scope state subtree and sometimes the whole Service state. + const state = getState().scope || getState(); + const movingInTime = state.get('websocketTransitioning'); + const hasChanges = delta.add || delta.update || delta.remove; + + if (hasChanges || movingInTime) { + if (isPausedSelector(state)) { + if (state.get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { + dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); + } + dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta }); } else { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, @@ -604,6 +622,31 @@ export function receiveNodesDelta(delta) { }; } +function updateFromNodesDeltaBuffer(dispatch, state) { + const isPaused = isPausedSelector(state); + const isBufferEmpty = state.get('nodesDeltaBuffer').isEmpty(); + + if (isPaused || isBufferEmpty) { + dispatch(resetNodesDeltaBuffer()); + } else { + dispatch(receiveNodesDelta(state.get('nodesDeltaBuffer').first())); + dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); + } +} + +export function clickResumeUpdate() { + return (dispatch, getServiceState) => { + dispatch({ + type: ActionTypes.CLICK_RESUME_UPDATE + }); + // Periodically merge buffered nodes deltas until the buffer is emptied. + nodesDeltaBufferUpdateTimer = setInterval( + () => updateFromNodesDeltaBuffer(dispatch, getServiceState().scope), + NODES_DELTA_BUFFER_FEED_INTERVAL, + ); + }; +} + export function receiveNodesForTopology(nodes, topologyId) { return { type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY, @@ -620,11 +663,7 @@ export function receiveTopologies(topologies) { topologies }); const state = getState(); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - dispatch - ); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -741,11 +780,7 @@ export function route(urlState) { // update all request workers with new options const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - dispatch - ); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0d59a68edf..6bd8ab7cc9 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -12,6 +12,7 @@ import Search from './search'; import Status from './status'; import Topologies from './topologies'; import TopologyOptions from './topology-options'; +import CloudFeature from './cloud-feature'; import { getApiDetails, getTopologies } from '../utils/web-api-utils'; import { focusSearch, @@ -29,6 +30,7 @@ import { } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; +import TimeTravel from './time-travel'; import ViewModeSelector from './view-mode-selector'; import NetworkSelector from './networks-selector'; import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar'; @@ -190,6 +192,10 @@ class App extends React.Component { + + {!isResourceViewMode && } + + {showingNetworkSelector && isGraphViewMode && } {!isResourceViewMode && } diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index 5da5c3c33e..410edc45f8 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -1,15 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; -import moment from 'moment'; import Plugins from './plugins'; -import { getUpdateBufferSize } from '../utils/update-buffer-utils'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { clickDownloadGraph, clickForceRelayout, - clickPauseUpdate, - clickResumeUpdate, toggleHelp, toggleTroubleshootingMenu, setContrastMode @@ -38,38 +34,18 @@ class Footer extends React.Component { } render() { - const { hostname, updatePausedAt, version, versionUpdate, contrastMode } = this.props; + const { hostname, version, versionUpdate, contrastMode } = this.props; const otherContrastModeTitle = contrastMode ? 'Switch to normal contrast' : 'Switch to high contrast'; const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, ' + 'but may shift nodes around)'; - - // pause button - const isPaused = updatePausedAt !== null; - const updateCount = getUpdateBufferSize(); - const hasUpdates = updateCount > 0; - const pausedAgo = moment(updatePausedAt).fromNow(); - const pauseTitle = isPaused - ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; - const pauseAction = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; - const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; - let pauseLabel = ''; - if (hasUpdates && isPaused) { - pauseLabel = `Paused +${updateCount}`; - } else if (hasUpdates && !isPaused) { - pauseLabel = `Resuming +${updateCount}`; - } else if (!hasUpdates && isPaused) { - pauseLabel = 'Paused'; - } - const versionUpdateTitle = versionUpdate ? `New version available: ${versionUpdate.version}. Click to download` : ''; return (
-
{versionUpdate &&
- - {pauseLabel !== '' && {pauseLabel}} - -
+ {label !== '' && {label}} + + + ); + } +} + +function mapStateToProps({ scope }) { + return { + hasUpdates: !scope.get('nodesDeltaBuffer').isEmpty(), + updateCount: scope.get('nodesDeltaBuffer').size, + updatePausedAt: scope.get('updatePausedAt'), + topologyViewMode: scope.get('topologyViewMode'), + currentTopology: scope.get('currentTopology'), + isPaused: isPausedSelector(scope), + }; +} + +export default connect( + mapStateToProps, + { + clickPauseUpdate, + clickResumeUpdate, + } +)(PauseButton); diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index c595b7bce9..cb0c0861d3 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,9 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel'; + + class Status extends React.Component { render() { - const {errorUrl, topologiesLoaded, filteredNodeCount, topology, websocketClosed} = this.props; + const { errorUrl, topologiesLoaded, filteredNodeCount, topology, + websocketClosed, showingCurrentState } = this.props; let title = ''; let text = 'Trying to reconnect...'; @@ -29,6 +33,11 @@ class Status extends React.Component { } classNames += ' status-stats'; showWarningIcon = false; + // TODO: Currently the stats are always pulled for the current state of the system, + // so they are incorrect when showing the past. This should be addressed somehow. + if (!showingCurrentState) { + text = ''; + } } return ( @@ -44,9 +53,10 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, + showingCurrentState: isWebsocketQueryingCurrentSelector(state), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), - websocketClosed: state.get('websocketClosed') + websocketClosed: state.get('websocketClosed'), }; } diff --git a/client/app/scripts/components/time-travel-timestamp.js b/client/app/scripts/components/time-travel-timestamp.js new file mode 100644 index 0000000000..5b44a6e4e8 --- /dev/null +++ b/client/app/scripts/components/time-travel-timestamp.js @@ -0,0 +1,58 @@ +import React from 'react'; +import moment from 'moment'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; + +import { isPausedSelector } from '../selectors/time-travel'; +import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; + + +class TimeTravelTimestamp extends React.Component { + componentDidMount() { + this.timer = setInterval(() => { + if (!this.props.isPaused) { + this.forceUpdate(); + } + }, TIMELINE_TICK_INTERVAL); + } + + componentWillUnmount() { + clearInterval(this.timer); + } + + renderTimestamp() { + const { isPaused, updatePausedAt, millisecondsInPast } = this.props; + const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast); + + return ( + + ); + } + + render() { + const { selected, onClick, millisecondsInPast } = this.props; + const isCurrent = (millisecondsInPast === 0); + + const className = classNames('button time-travel-timestamp', { + selected, current: isCurrent + }); + + return ( + + + {isCurrent ? 'now' : this.renderTimestamp()} + + + + ); + } +} + +function mapStateToProps({ scope }) { + return { + isPaused: isPausedSelector(scope), + updatePausedAt: scope.get('updatePausedAt'), + }; +} + +export default connect(mapStateToProps)(TimeTravelTimestamp); diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js new file mode 100644 index 0000000000..cb206acf41 --- /dev/null +++ b/client/app/scripts/components/time-travel.js @@ -0,0 +1,288 @@ +import React from 'react'; +import moment from 'moment'; +import Slider from 'rc-slider'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; + +import PauseButton from './pause-button'; +import TimeTravelTimestamp from './time-travel-timestamp'; +import { trackMixpanelEvent } from '../utils/tracking-utils'; +import { + websocketQueryInPast, + startWebsocketTransitionLoader, + clickResumeUpdate, +} from '../actions/app-actions'; + +import { + TIMELINE_SLIDER_UPDATE_INTERVAL, + TIMELINE_DEBOUNCE_INTERVAL, +} from '../constants/timer'; + + +const sliderRanges = { + last15Minutes: { + label: 'Last 15 minutes', + getStart: () => moment().utc().subtract(15, 'minutes'), + }, + last1Hour: { + label: 'Last 1 hour', + getStart: () => moment().utc().subtract(1, 'hour'), + }, + last6Hours: { + label: 'Last 6 hours', + getStart: () => moment().utc().subtract(6, 'hours'), + }, + last24Hours: { + label: 'Last 24 hours', + getStart: () => moment().utc().subtract(24, 'hours'), + }, + last7Days: { + label: 'Last 7 days', + getStart: () => moment().utc().subtract(7, 'days'), + }, + last30Days: { + label: 'Last 30 days', + getStart: () => moment().utc().subtract(30, 'days'), + }, + last90Days: { + label: 'Last 90 days', + getStart: () => moment().utc().subtract(90, 'days'), + }, + last1Year: { + label: 'Last 1 year', + getStart: () => moment().subtract(1, 'year'), + }, + todaySoFar: { + label: 'Today so far', + getStart: () => moment().utc().startOf('day'), + }, + thisWeekSoFar: { + label: 'This week so far', + getStart: () => moment().utc().startOf('week'), + }, + thisMonthSoFar: { + label: 'This month so far', + getStart: () => moment().utc().startOf('month'), + }, + thisYearSoFar: { + label: 'This year so far', + getStart: () => moment().utc().startOf('year'), + }, +}; + +class TimeTravel extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + showSliderPanel: false, + millisecondsInPast: 0, + rangeOptionSelected: sliderRanges.last1Hour, + }; + + this.renderRangeOption = this.renderRangeOption.bind(this); + this.handleTimestampClick = this.handleTimestampClick.bind(this); + this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this); + this.handleSliderChange = this.handleSliderChange.bind(this); + + this.debouncedUpdateTimestamp = debounce( + this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); + this.debouncedTrackSliderChange = debounce( + this.trackSliderChange.bind(this), TIMELINE_DEBOUNCE_INTERVAL); + } + + componentDidMount() { + // Force periodic re-renders to update the slider position as time goes by. + this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL); + } + + componentWillUnmount() { + clearInterval(this.timer); + this.updateTimestamp(); + } + + handleSliderChange(sliderValue) { + let millisecondsInPast = this.getRangeMilliseconds() - sliderValue; + + // If the slider value is less than 1s away from the right-end (current time), + // assume we meant the current time - this is important for the '... so far' + // ranges where the range of values changes over time. + if (millisecondsInPast < 1000) { + millisecondsInPast = 0; + } + + this.setState({ millisecondsInPast }); + this.props.startWebsocketTransitionLoader(); + this.debouncedUpdateTimestamp(millisecondsInPast); + + this.debouncedTrackSliderChange(); + } + + handleRangeOptionClick(rangeOption) { + this.setState({ rangeOptionSelected: rangeOption }); + + const rangeMilliseconds = this.getRangeMilliseconds(rangeOption); + if (this.state.millisecondsInPast > rangeMilliseconds) { + this.setState({ millisecondsInPast: rangeMilliseconds }); + this.updateTimestamp(rangeMilliseconds); + this.props.startWebsocketTransitionLoader(); + } + + trackMixpanelEvent('scope.time.range.select', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + label: rangeOption.label, + }); + } + + handleJumpToNowClick() { + this.setState({ + showSliderPanel: false, + millisecondsInPast: 0, + rangeOptionSelected: sliderRanges.last1Hour, + }); + this.updateTimestamp(); + this.props.startWebsocketTransitionLoader(); + + trackMixpanelEvent('scope.time.now.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + } + + handleTimestampClick() { + const showSliderPanel = !this.state.showSliderPanel; + this.setState({ showSliderPanel }); + + trackMixpanelEvent('scope.time.timestamp.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + showSliderPanel, + }); + } + + updateTimestamp(millisecondsInPast = 0) { + this.props.websocketQueryInPast(millisecondsInPast); + this.props.clickResumeUpdate(); + } + + getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) { + return moment().diff(rangeOption.getStart()); + } + + trackSliderChange() { + trackMixpanelEvent('scope.time.slider.change', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + } + + renderRangeOption(rangeOption) { + const handleClick = () => { this.handleRangeOptionClick(rangeOption); }; + const selected = (this.state.rangeOptionSelected.label === rangeOption.label); + const className = classNames('option', { selected }); + + return ( + + {rangeOption.label} + + ); + } + + renderJumpToNowButton() { + return ( + + + + ); + } + + renderTimeSlider() { + const { millisecondsInPast } = this.state; + const rangeMilliseconds = this.getRangeMilliseconds(); + + return ( + + ); + } + + render() { + const { websocketTransitioning, hasTimeTravel } = this.props; + const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state; + const lowerCaseLabel = rangeOptionSelected.label.toLowerCase(); + const isCurrent = (millisecondsInPast === 0); + + // Don't render the time travel control if it's not explicitly enabled for this instance. + if (!hasTimeTravel) return null; + + return ( +
+ {showSliderPanel &&
+
+
+ {this.renderRangeOption(sliderRanges.last15Minutes)} + {this.renderRangeOption(sliderRanges.last1Hour)} + {this.renderRangeOption(sliderRanges.last6Hours)} + {this.renderRangeOption(sliderRanges.last24Hours)} +
+
+ {this.renderRangeOption(sliderRanges.last7Days)} + {this.renderRangeOption(sliderRanges.last30Days)} + {this.renderRangeOption(sliderRanges.last90Days)} + {this.renderRangeOption(sliderRanges.last1Year)} +
+
+ {this.renderRangeOption(sliderRanges.todaySoFar)} + {this.renderRangeOption(sliderRanges.thisWeekSoFar)} + {this.renderRangeOption(sliderRanges.thisMonthSoFar)} + {this.renderRangeOption(sliderRanges.thisYearSoFar)} +
+
+ Move the slider to explore {lowerCaseLabel} + {this.renderTimeSlider()} +
} +
+ {websocketTransitioning &&
+ +
} + + {!isCurrent && this.renderJumpToNowButton()} + +
+
+ ); + } +} + +function mapStateToProps({ scope, root }, { params }) { + const cloudInstance = root.instances[params.orgId] || {}; + const featureFlags = cloudInstance.featureFlags || []; + return { + hasTimeTravel: featureFlags.includes('timeline-control'), + websocketTransitioning: scope.get('websocketTransitioning'), + topologyViewMode: scope.get('topologyViewMode'), + currentTopology: scope.get('currentTopology'), + }; +} + +export default connect( + mapStateToProps, + { + websocketQueryInPast, + startWebsocketTransitionLoader, + clickResumeUpdate, + } +)(TimeTravel); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index c84a8a6452..d610ad8948 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -3,9 +3,12 @@ import { zipObject } from 'lodash'; const ACTION_TYPES = [ 'ADD_QUERY_FILTER', 'BLUR_SEARCH', + 'BUFFER_NODES_DELTA', 'CACHE_ZOOM_STATE', + 'CHANGE_INSTANCE', 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', + 'CLEAR_NODES_DELTA_BUFFER', 'CLICK_BACKGROUND', 'CLICK_CLOSE_DETAILS', 'CLICK_CLOSE_TERMINAL', @@ -18,54 +21,57 @@ const ACTION_TYPES = [ 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', + 'CONSOLIDATE_NODES_DELTA_BUFFER', 'DEBUG_TOOLBAR_INTERFERING', 'DESELECT_NODE', - 'DO_CONTROL', 'DO_CONTROL_ERROR', 'DO_CONTROL_SUCCESS', + 'DO_CONTROL', 'DO_SEARCH', 'ENTER_EDGE', 'ENTER_NODE', 'FOCUS_SEARCH', 'HIDE_HELP', 'HOVER_METRIC', - 'UNHOVER_METRIC', 'LEAVE_EDGE', 'LEAVE_NODE', + 'OPEN_WEBSOCKET', 'PIN_METRIC', + 'PIN_NETWORK', 'PIN_SEARCH', - 'UNPIN_METRIC', - 'UNPIN_SEARCH', - 'OPEN_WEBSOCKET', + 'POP_NODES_DELTA_BUFFER', + 'RECEIVE_API_DETAILS', 'RECEIVE_CONTROL_NODE_REMOVED', - 'RECEIVE_CONTROL_PIPE', 'RECEIVE_CONTROL_PIPE_STATUS', + 'RECEIVE_CONTROL_PIPE', + 'RECEIVE_ERROR', 'RECEIVE_NODE_DETAILS', - 'RECEIVE_NODES', 'RECEIVE_NODES_DELTA', 'RECEIVE_NODES_FOR_TOPOLOGY', + 'RECEIVE_NODES', 'RECEIVE_NOT_FOUND', + 'RECEIVE_SERVICE_IMAGES', 'RECEIVE_TOPOLOGIES', - 'RECEIVE_API_DETAILS', - 'RECEIVE_ERROR', + 'REQUEST_SERVICE_IMAGES', 'RESET_LOCAL_VIEW_STATE', 'ROUTE_TOPOLOGY', - 'SHOW_HELP', - 'SET_VIEWPORT_DIMENSIONS', - 'SET_EXPORTING_GRAPH', 'SELECT_NETWORK', - 'TOGGLE_TROUBLESHOOTING_MENU', - 'PIN_NETWORK', - 'UNPIN_NETWORK', - 'SHOW_NETWORKS', + 'SET_EXPORTING_GRAPH', 'SET_RECEIVED_NODES_DELTA', - 'SORT_ORDER_CHANGED', 'SET_VIEW_MODE', - 'CHANGE_INSTANCE', - 'TOGGLE_CONTRAST_MODE', + 'SET_VIEWPORT_DIMENSIONS', + 'SHOW_HELP', + 'SHOW_NETWORKS', 'SHUTDOWN', - 'REQUEST_SERVICE_IMAGES', - 'RECEIVE_SERVICE_IMAGES' + 'SORT_ORDER_CHANGED', + 'START_WEBSOCKET_TRANSITION_LOADER', + 'TOGGLE_CONTRAST_MODE', + 'TOGGLE_TROUBLESHOOTING_MENU', + 'UNHOVER_METRIC', + 'UNPIN_METRIC', + 'UNPIN_NETWORK', + 'UNPIN_SEARCH', + 'WEBSOCKET_QUERY_MILLISECONDS_IN_PAST', ]; export default zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/constants/limits.js b/client/app/scripts/constants/limits.js index afb2c79382..56e80aa409 100644 --- a/client/app/scripts/constants/limits.js +++ b/client/app/scripts/constants/limits.js @@ -1,2 +1,3 @@ export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5; +export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100; diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index c828bfda1e..fdb9d2e069 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -1,7 +1,14 @@ /* Intervals in ms */ -export const API_INTERVAL = 30000; -export const TOPOLOGY_INTERVAL = 5000; +export const API_REFRESH_INTERVAL = 30000; +export const TOPOLOGY_REFRESH_INTERVAL = 5000; +export const NODES_DELTA_BUFFER_FEED_INTERVAL = 1000; + +export const TOPOLOGY_LOADER_DELAY = 100; export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10; export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200; export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500; +export const TIMELINE_DEBOUNCE_INTERVAL = 500; + +export const TIMELINE_TICK_INTERVAL = 100; +export const TIMELINE_SLIDER_UPDATE_INTERVAL = 10000; diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index cfd2107ab4..a70df2df39 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -2,9 +2,9 @@ import { is, fromJS } from 'immutable'; import expect from 'expect'; import { TABLE_VIEW_MODE } from '../../constants/naming'; -// Root reducer test suite using Jasmine matchers import { constructEdgeId } from '../../utils/layouter-utils'; +// Root reducer test suite using Jasmine matchers describe('RootReducer', () => { const ActionTypes = require('../../constants/action-types').default; const reducer = require('../root').default; @@ -512,8 +512,6 @@ describe('RootReducer', () => { nextState = reducer(nextState, OpenWebsocketAction); expect(nextState.get('websocketClosed')).toBeFalsy(); - // opened socket clears nodes - expect(nextState.get('nodes').toJS()).toEqual({}); }); // adjacency test @@ -538,12 +536,19 @@ describe('RootReducer', () => { let nextState = initialState; nextState = reducer(nextState, ReceiveTopologiesAction); nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeTruthy(); + + nextState = reducer(nextState, ReceiveNodesDeltaAction); expect(isTopologyEmpty(nextState)).toBeFalsy(); nextState = reducer(nextState, ClickTopology2Action); + nextState = reducer(nextState, ReceiveNodesDeltaAction); expect(isTopologyEmpty(nextState)).toBeTruthy(); nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeTruthy(); + + nextState = reducer(nextState, ReceiveNodesDeltaAction); expect(isTopologyEmpty(nextState)).toBeFalsy(); }); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 3f731a8287..e5920517c6 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -1,8 +1,15 @@ /* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */ import debug from 'debug'; +import moment from 'moment'; import { size, each, includes, isEqual } from 'lodash'; -import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, - OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable'; +import { + fromJS, + is as isDeepEqual, + List as makeList, + Map as makeMap, + OrderedMap as makeOrderedMap, + Set as makeSet, +} from 'immutable'; import ActionTypes from '../constants/action-types'; import { @@ -15,6 +22,7 @@ import { isResourceViewModeSelector, } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; +import { consolidateNodesDeltas } from '../utils/nodes-delta-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -56,6 +64,7 @@ export const initialState = makeMap({ mouseOverNodeId: null, nodeDetails: makeOrderedMap(), // nodeId -> details nodes: makeOrderedMap(), // nodeId -> node + nodesDeltaBuffer: makeList(), nodesLoaded: false, // nodes cache, infrequently updated, used for search & resource view nodesByTopology: makeMap(), // topologyId -> nodes @@ -78,11 +87,13 @@ export const initialState = makeMap({ topologyOptions: makeOrderedMap(), // topologyId -> options topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl topologyViewMode: GRAPH_VIEW_MODE, - updatePausedAt: null, // Date + updatePausedAt: null, version: '...', versionUpdate: null, viewport: makeMap(), websocketClosed: false, + websocketTransitioning: false, + websocketQueryMillisecondsInPast: 0, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -285,7 +296,8 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_PAUSE_UPDATE: { - return state.set('updatePausedAt', new Date()); + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast)); } case ActionTypes.CLICK_RELATIVE: { @@ -331,7 +343,8 @@ export function rootReducer(state = initialState, action) { state = resumeUpdate(state); state = closeAllNodeDetails(state); - if (action.topologyId !== state.get('currentTopologyId')) { + const currentTopologyId = state.get('currentTopologyId'); + if (action.topologyId !== currentTopologyId) { state = setTopology(state, action.topologyId); state = clearNodes(state); } @@ -339,11 +352,47 @@ export function rootReducer(state = initialState, action) { return state; } + // + // websockets + // + + case ActionTypes.OPEN_WEBSOCKET: { + return state.set('websocketClosed', false); + } + + case ActionTypes.START_WEBSOCKET_TRANSITION_LOADER: { + return state.set('websocketTransitioning', true); + } + + case ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST: { + return state.set('websocketQueryMillisecondsInPast', action.millisecondsInPast); + } + case ActionTypes.CLOSE_WEBSOCKET: { - if (!state.get('websocketClosed')) { - state = state.set('websocketClosed', true); - } - return state; + return state.set('websocketClosed', true); + } + + // + // nodes delta buffer + // + + case ActionTypes.CLEAR_NODES_DELTA_BUFFER: { + return state.update('nodesDeltaBuffer', buffer => buffer.clear()); + } + + case ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER: { + const firstDelta = state.getIn(['nodesDeltaBuffer', 0]); + const secondDelta = state.getIn(['nodesDeltaBuffer', 1]); + const deltaUnion = consolidateNodesDeltas(firstDelta, secondDelta); + return state.update('nodesDeltaBuffer', buffer => buffer.shift().set(0, deltaUnion)); + } + + case ActionTypes.POP_NODES_DELTA_BUFFER: { + return state.update('nodesDeltaBuffer', buffer => buffer.shift()); + } + + case ActionTypes.BUFFER_NODES_DELTA: { + return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta)); } // @@ -479,13 +528,6 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.OPEN_WEBSOCKET: { - // flush nodes cache after re-connect - state = state.update('nodes', nodes => nodes.clear()); - state = state.set('websocketClosed', false); - return state; - } - case ActionTypes.DO_CONTROL_ERROR: { return state.setIn(['controlStatus', action.nodeId], makeMap({ pending: false, @@ -567,18 +609,22 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_NODES_DELTA: { - const emptyMessage = !action.delta.add && !action.delta.remove - && !action.delta.update; - - if (!emptyMessage) { - log('RECEIVE_NODES_DELTA', - 'remove', size(action.delta.remove), - 'update', size(action.delta.update), - 'add', size(action.delta.add)); - } + log('RECEIVE_NODES_DELTA', + 'remove', size(action.delta.remove), + 'update', size(action.delta.update), + 'add', size(action.delta.add)); state = state.set('errorUrl', null); + // When moving in time, we will consider the transition complete + // only when the first batch of nodes delta has been received. We + // do that because we want to keep the previous state blurred instead + // of transitioning over an empty state like when switching topologies. + if (state.get('websocketTransitioning')) { + state = state.set('websocketTransitioning', false); + state = clearNodes(state); + } + // nodes that no longer exist each(action.delta.remove, (nodeId) => { // in case node disappears before mouseleave event diff --git a/client/app/scripts/selectors/resource-view/layout.js b/client/app/scripts/selectors/resource-view/layout.js index 2f002e9730..615c2944ea 100644 --- a/client/app/scripts/selectors/resource-view/layout.js +++ b/client/app/scripts/selectors/resource-view/layout.js @@ -42,11 +42,11 @@ export const layerVerticalPositionByTopologyIdSelector = createSelector( ], (topologiesIds) => { let yPositions = makeMap(); - let currentY = RESOURCES_LAYER_PADDING; + let yCumulative = RESOURCES_LAYER_PADDING; topologiesIds.forEach((topologyId) => { - currentY -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING; - yPositions = yPositions.set(topologyId, currentY); + yCumulative -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING; + yPositions = yPositions.set(topologyId, yCumulative); }); return yPositions; diff --git a/client/app/scripts/selectors/time-travel.js b/client/app/scripts/selectors/time-travel.js new file mode 100644 index 0000000000..ae84cb43d8 --- /dev/null +++ b/client/app/scripts/selectors/time-travel.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; + + +export const isPausedSelector = createSelector( + [ + state => state.get('updatePausedAt') + ], + updatePausedAt => updatePausedAt !== null +); + +export const isWebsocketQueryingCurrentSelector = createSelector( + [ + state => state.get('websocketQueryMillisecondsInPast') + ], + websocketQueryMillisecondsInPast => websocketQueryMillisecondsInPast === 0 +); diff --git a/client/app/scripts/utils/__tests__/web-api-utils-test.js b/client/app/scripts/utils/__tests__/web-api-utils-test.js index f98fada427..fc467577bd 100644 --- a/client/app/scripts/utils/__tests__/web-api-utils-test.js +++ b/client/app/scripts/utils/__tests__/web-api-utils-test.js @@ -1,6 +1,6 @@ import {OrderedMap as makeOrderedMap} from 'immutable'; -import { buildOptionsQuery, basePath, getApiPath, getWebsocketUrl } from '../web-api-utils'; +import { buildUrlQuery, basePath, getApiPath, getWebsocketUrl } from '../web-api-utils'; describe('WebApiUtils', () => { describe('basePath', () => { @@ -21,13 +21,13 @@ describe('WebApiUtils', () => { }); }); - describe('buildOptionsQuery', () => { + describe('buildUrlQuery', () => { it('should handle empty options', () => { - expect(buildOptionsQuery(makeOrderedMap({}))).toBe(''); + expect(buildUrlQuery(makeOrderedMap({}))).toBe(''); }); it('should combine multiple options', () => { - expect(buildOptionsQuery(makeOrderedMap([ + expect(buildUrlQuery(makeOrderedMap([ ['foo', 2], ['bar', 4] ]))).toBe('foo=2&bar=4'); diff --git a/client/app/scripts/utils/nodes-delta-utils.js b/client/app/scripts/utils/nodes-delta-utils.js new file mode 100644 index 0000000000..0c1f01983c --- /dev/null +++ b/client/app/scripts/utils/nodes-delta-utils.js @@ -0,0 +1,60 @@ +import debug from 'debug'; +import { union, size, map, find, reject, each } from 'lodash'; + +const log = debug('scope:nodes-delta-utils'); + + +// TODO: It would be nice to have a unit test for this function. +export function consolidateNodesDeltas(first, second) { + let toAdd = union(first.add, second.add); + let toUpdate = union(first.update, second.update); + let toRemove = union(first.remove, second.remove); + log('Consolidating delta buffer', + 'add', size(toAdd), + 'update', size(toUpdate), + 'remove', size(toRemove)); + + // check if an added node in first was updated in second -> add second update + toAdd = map(toAdd, (node) => { + const updateNode = find(second.update, {id: node.id}); + if (updateNode) { + toUpdate = reject(toUpdate, {id: node.id}); + return updateNode; + } + return node; + }); + + // check if an updated node in first was updated in second -> updated second update + // no action needed, successive updates are fine + + // check if an added node in first was removed in second -> dont add, dont remove + each(first.add, (node) => { + const removedNode = find(second.remove, {id: node.id}); + if (removedNode) { + toAdd = reject(toAdd, {id: node.id}); + toRemove = reject(toRemove, {id: node.id}); + } + }); + + // check if an updated node in first was removed in second -> remove + each(first.update, (node) => { + const removedNode = find(second.remove, {id: node.id}); + if (removedNode) { + toUpdate = reject(toUpdate, {id: node.id}); + } + }); + + // check if an removed node in first was added in second -> update + // remove -> add is fine for the store + + log('Consolidated delta buffer', + 'add', size(toAdd), + 'update', size(toUpdate), + 'remove', size(toRemove)); + + return { + add: toAdd.length > 0 ? toAdd : null, + update: toUpdate.length > 0 ? toUpdate : null, + remove: toRemove.length > 0 ? toRemove : null + }; +} diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 82cf1f65c8..d67c3d5302 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -1,6 +1,7 @@ import { endsWith } from 'lodash'; import { Set as makeSet, List as makeList } from 'immutable'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel'; import { isResourceViewModeSelector } from '../selectors/topology'; import { pinnedMetricSelector } from '../selectors/node-metric'; @@ -133,15 +134,23 @@ export function getCurrentTopologyOptions(state) { return state.getIn(['currentTopology', 'options']); } -export function isTopologyEmpty(state) { - // Consider a topology in the resource view empty if it has no pinned metric. - const resourceViewEmpty = isResourceViewModeSelector(state) && !pinnedMetricSelector(state); - // Otherwise (in graph and table view), we only look at the node count. +export function isTopologyNodeCountZero(state) { const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0); - const nodesEmpty = nodeCount === 0 && state.get('nodes').size === 0; - return resourceViewEmpty || nodesEmpty; + return nodeCount === 0 && isWebsocketQueryingCurrentSelector(state); +} + +export function isNodesDisplayEmpty(state) { + // Consider a topology in the resource view empty if it has no pinned metric. + if (isResourceViewModeSelector(state)) { + return !pinnedMetricSelector(state); + } + // Otherwise (in graph and table view), we only look at the nodes content. + return state.get('nodes').isEmpty(); } +export function isTopologyEmpty(state) { + return isTopologyNodeCountZero(state) || isNodesDisplayEmpty(state); +} export function getAdjacentNodes(state, originNodeId) { let adjacentNodes = makeSet(); diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js deleted file mode 100644 index cd65da5516..0000000000 --- a/client/app/scripts/utils/update-buffer-utils.js +++ /dev/null @@ -1,114 +0,0 @@ -import debug from 'debug'; -import Immutable from 'immutable'; -import { union, size, map, find, reject, each } from 'lodash'; - -import { receiveNodesDelta } from '../actions/app-actions'; - -const log = debug('scope:update-buffer-utils'); -const makeList = Immutable.List; -const feedInterval = 1000; -const bufferLength = 100; - -let deltaBuffer = makeList(); -let updateTimer = null; - -function isPaused(getState) { - return getState().get('updatePausedAt') !== null; -} - -export function resetUpdateBuffer() { - clearTimeout(updateTimer); - deltaBuffer = deltaBuffer.clear(); -} - -function maybeUpdate(getState) { - if (isPaused(getState)) { - clearTimeout(updateTimer); - resetUpdateBuffer(); - } else { - if (deltaBuffer.size > 0) { - const delta = deltaBuffer.first(); - deltaBuffer = deltaBuffer.shift(); - receiveNodesDelta(delta); - } - if (deltaBuffer.size > 0) { - updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval); - } - } -} - -// consolidate first buffer entry with second -function consolidateBuffer() { - const first = deltaBuffer.first(); - deltaBuffer = deltaBuffer.shift(); - const second = deltaBuffer.first(); - let toAdd = union(first.add, second.add); - let toUpdate = union(first.update, second.update); - let toRemove = union(first.remove, second.remove); - log('Consolidating delta buffer', 'add', size(toAdd), 'update', - size(toUpdate), 'remove', size(toRemove)); - - // check if an added node in first was updated in second -> add second update - toAdd = map(toAdd, (node) => { - const updateNode = find(second.update, {id: node.id}); - if (updateNode) { - toUpdate = reject(toUpdate, {id: node.id}); - return updateNode; - } - return node; - }); - - // check if an updated node in first was updated in second -> updated second update - // no action needed, successive updates are fine - - // check if an added node in first was removed in second -> dont add, dont remove - each(first.add, (node) => { - const removedNode = find(second.remove, {id: node.id}); - if (removedNode) { - toAdd = reject(toAdd, {id: node.id}); - toRemove = reject(toRemove, {id: node.id}); - } - }); - - // check if an updated node in first was removed in second -> remove - each(first.update, (node) => { - const removedNode = find(second.remove, {id: node.id}); - if (removedNode) { - toUpdate = reject(toUpdate, {id: node.id}); - } - }); - - // check if an removed node in first was added in second -> update - // remove -> add is fine for the store - - // update buffer - log('Consolidated delta buffer', 'add', size(toAdd), 'update', - size(toUpdate), 'remove', size(toRemove)); - deltaBuffer.set(0, { - add: toAdd.length > 0 ? toAdd : null, - update: toUpdate.length > 0 ? toUpdate : null, - remove: toRemove.length > 0 ? toRemove : null - }); -} - -export function bufferDeltaUpdate(delta) { - if (delta.add === null && delta.update === null && delta.remove === null) { - log('Discarding empty nodes delta'); - return; - } - - if (deltaBuffer.size >= bufferLength) { - consolidateBuffer(); - } - - deltaBuffer = deltaBuffer.push(delta); - log('Buffering node delta, new size', deltaBuffer.size); -} - -export function getUpdateBufferSize() { - return deltaBuffer.size; -} - -export function resumeUpdate(getState) { - maybeUpdate(getState); -} diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 41e9fa7e29..be177efac1 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,7 +1,8 @@ import debug from 'debug'; +import moment from 'moment'; import reqwest from 'reqwest'; -import defaults from 'lodash/defaults'; -import { Map as makeMap, List } from 'immutable'; +import { defaults } from 'lodash'; +import { fromJS, Map as makeMap, List } from 'immutable'; import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError, receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError, @@ -9,8 +10,11 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr receiveControlSuccess, receiveTopologies, receiveNotFound, receiveNodesForTopology } from '../actions/app-actions'; +import { getCurrentTopologyUrl } from '../utils/topology-utils'; import { layersTopologyIdsSelector } from '../selectors/resource-view/layout'; -import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer'; +import { activeTopologyOptionsSelector } from '../selectors/topology'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel'; +import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer'; const log = debug('scope:web-api-utils'); @@ -34,25 +38,25 @@ const csrfToken = (() => { let socket; let reconnectTimer = 0; -let currentUrl = null; -let currentOptions = null; let topologyTimer = 0; let apiDetailsTimer = 0; let controlErrorTimer = 0; -let createWebsocketAt = 0; -let firstMessageOnWebsocketAt = 0; +let currentUrl = null; +let createWebsocketAt = null; +let firstMessageOnWebsocketAt = null; let continuePolling = true; -export function buildOptionsQuery(options) { - if (options) { - return options.map((value, param) => { - if (List.isList(value)) { - value = value.join(','); - } - return `${param}=${value}`; - }).join('&'); - } - return ''; +export function buildUrlQuery(params) { + if (!params) return ''; + + // Ignore the entries with values `null` or `undefined`. + return params.map((value, param) => { + if (value === undefined || value === null) return null; + if (List.isList(value)) { + value = value.join(','); + } + return `${param}=${value}`; + }).filter(s => s).join('&'); } export function basePath(urlPath) { @@ -93,7 +97,16 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l return `${wsProto}://${host}${process.env.SCOPE_API_PREFIX || ''}${basePath(pathname)}`; } -function createWebsocket(topologyUrl, optionsQuery, dispatch) { +function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) { + const query = buildUrlQuery(fromJS({ + t: updateFrequency, + timestamp: queryTimestamp, + ...topologyOptions.toJS(), + })); + return `${getWebsocketUrl()}${topologyUrl}/ws?${query}`; +} + +function createWebsocket(websocketUrl, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -104,30 +117,31 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) { // profiling createWebsocketAt = new Date(); - firstMessageOnWebsocketAt = 0; + firstMessageOnWebsocketAt = null; - socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`); + socket = new WebSocket(websocketUrl); socket.onopen = () => { + log(`Opening websocket to ${websocketUrl}`); dispatch(openWebsocket()); }; socket.onclose = () => { clearTimeout(reconnectTimer); - log(`Closing websocket to ${topologyUrl}`, socket.readyState); + log(`Closing websocket to ${websocketUrl}`, socket.readyState); socket = null; dispatch(closeWebsocket()); if (continuePolling) { reconnectTimer = setTimeout(() => { - createWebsocket(topologyUrl, optionsQuery, dispatch); + createWebsocket(websocketUrl, dispatch); }, reconnectTimerInterval); } }; socket.onerror = () => { - log(`Error in websocket to ${topologyUrl}`); - dispatch(receiveError(currentUrl)); + log(`Error in websocket to ${websocketUrl}`); + dispatch(receiveError(websocketUrl)); }; socket.onmessage = (event) => { @@ -170,7 +184,7 @@ function getNodesForTopologies(getState, dispatch, topologyIds, topologyOptions getState().get('topologyUrlsById') .filter((_, topologyId) => topologyIds.contains(topologyId)) .reduce((sequence, topologyUrl, topologyId) => sequence.then(() => { - const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId)); + const optionsQuery = buildUrlQuery(topologyOptions.get(topologyId)); return doRequest({ url: `${getApiPath()}${topologyUrl}?${optionsQuery}` }); }) .then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))), @@ -200,7 +214,7 @@ export function getTopologies(options, dispatch, initialPoll) { // Used to resume polling when navigating between pages in Weave Cloud. continuePolling = initialPoll === true ? true : continuePolling; clearTimeout(topologyTimer); - const optionsQuery = buildOptionsQuery(options); + const optionsQuery = buildUrlQuery(options); const url = `${getApiPath()}/api/topology?${optionsQuery}`; doRequest({ url, @@ -209,7 +223,7 @@ export function getTopologies(options, dispatch, initialPoll) { dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { getTopologies(options, dispatch); - }, TOPOLOGY_INTERVAL); + }, TOPOLOGY_REFRESH_INTERVAL); } }, error: (req) => { @@ -219,26 +233,32 @@ export function getTopologies(options, dispatch, initialPoll) { if (continuePolling) { topologyTimer = setTimeout(() => { getTopologies(options, dispatch); - }, TOPOLOGY_INTERVAL); + }, TOPOLOGY_REFRESH_INTERVAL); } } }); } -// TODO: topologyUrl and options are always used for the current topology so they as arguments -// can be replaced by the `state` and then retrieved here internally from selectors. -export function getNodesDelta(topologyUrl, options, dispatch) { - const optionsQuery = buildOptionsQuery(options); +function getWebsocketQueryTimestamp(state) { + // The timestamp query parameter will be used only if it's in the past. + if (isWebsocketQueryingCurrentSelector(state)) return null; + + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + return moment().utc().subtract(millisecondsInPast).toISOString(); +} + +export function updateWebsocketChannel(state, dispatch) { + const topologyUrl = getCurrentTopologyUrl(state); + const topologyOptions = activeTopologyOptionsSelector(state); + const queryTimestamp = getWebsocketQueryTimestamp(state); + const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp); // Only recreate websocket if url changed or if forced (weave cloud instance reload); - // Check for truthy options and that options have changed. - const isNewOptions = currentOptions && currentOptions !== optionsQuery; - const isNewUrl = topologyUrl !== currentUrl || isNewOptions; + const isNewUrl = websocketUrl !== currentUrl; // `topologyUrl` can be undefined initially, so only create a socket if it is truthy // and no socket exists, or if we get a new url. - if ((topologyUrl && !socket) || (topologyUrl && isNewUrl)) { - createWebsocket(topologyUrl, optionsQuery, dispatch); - currentUrl = topologyUrl; - currentOptions = optionsQuery; + if (topologyUrl && (!socket || isNewUrl)) { + createWebsocket(websocketUrl, dispatch); + currentUrl = websocketUrl; } } @@ -250,7 +270,7 @@ export function getNodeDetails(topologyUrlsById, currentTopologyId, options, nod let urlComponents = [getApiPath(), topologyUrl, '/', encodeURIComponent(obj.id)]; if (currentTopologyId === obj.topologyId) { // Only forward filters for nodes in the current topology - const optionsQuery = buildOptionsQuery(options); + const optionsQuery = buildUrlQuery(options); urlComponents = urlComponents.concat(['?', optionsQuery]); } const url = urlComponents.join(''); @@ -288,7 +308,7 @@ export function getApiDetails(dispatch) { if (continuePolling) { apiDetailsTimer = setTimeout(() => { getApiDetails(dispatch); - }, API_INTERVAL); + }, API_REFRESH_INTERVAL); } }, error: (req) => { @@ -297,7 +317,7 @@ export function getApiDetails(dispatch) { if (continuePolling) { apiDetailsTimer = setTimeout(() => { getApiDetails(dispatch); - }, API_INTERVAL / 2); + }, API_REFRESH_INTERVAL / 2); } } }); @@ -407,6 +427,6 @@ export function teardownWebsockets() { socket.onopen = null; socket.close(); socket = null; - currentOptions = null; + currentUrl = null; } } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 7588841df2..3f08b66cf6 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -35,6 +35,10 @@ transition: opacity .5s $base-ease; } +.blinkable { + animation: blinking 1.5s infinite $base-ease; +} + .hang-around { transition-delay: .5s; } @@ -48,13 +52,40 @@ } .overlay-wrapper { + align-items: center; background-color: fade-out($background-average-color, 0.1); border-radius: 4px; color: $text-tertiary-color; display: flex; font-size: 0.7rem; + justify-content: center; padding: 5px; position: absolute; + bottom: 11px; + + a { + @extend .btn-opacity; + border: 1px solid transparent; + border-radius: 4px; + color: $text-secondary-color; + cursor: pointer; + padding: 1px 3px; + + .fa { + font-size: 150%; + position: relative; + top: 2px; + } + + &:hover, &.selected { + border: 1px solid $text-tertiary-color; + } + + &.active { + & > * { @extend .blinkable; } + border: 1px solid $text-tertiary-color; + } + } } .btn-opacity { @@ -137,17 +168,18 @@ } } + +.rc-slider { + .rc-slider-step { cursor: pointer; } + .rc-slider-track { background-color: $text-tertiary-color; } + .rc-slider-rail { background-color: $border-light-color; } + .rc-slider-handle { border-color: $text-tertiary-color; } +} + .footer { @extend .overlay-wrapper; - bottom: 11px; right: 43px; - a { - @extend .btn-opacity; - color: $text-secondary-color; - cursor: pointer; - } - &-status { margin-right: 1em; } @@ -162,41 +194,85 @@ text-transform: uppercase; } + &-tools { + display: flex; + } + &-icon { margin-left: 0.5em; - padding: 4px 3px; - color: $text-color; - position: relative; - top: -1px; - border: 1px solid transparent; - border-radius: 4px; + } - &:hover { - border: 1px solid $text-tertiary-color; + .tooltip { + // above everything + z-index: 20000; + } +} + +.nodes-wrapper { + @extend .hideable; + + &.blurred { opacity: 0.2; } +} + +.time-travel { + @extend .overlay-wrapper; + display: block; + right: 530px; + + &-status { + display: flex; + align-items: center; + justify-content: flex-end; + + .time-travel-jump-loader { + font-size: 1rem; } - .fa { - font-size: 150%; - position: relative; - top: 2px; + .time-travel-timestamp-info, .pause-text { + font-size: 115%; + margin-right: 5px; } - &-active { - border: 1px solid $text-tertiary-color; - animation: blinking 1.5s infinite $base-ease; + .button { margin-left: 0.5em; } + + .time-travel-timestamp:not(.current) { + & > * { @extend .blinkable; } + font-weight: bold; } } - &-icon &-label { - margin-right: 0.5em; - } + &-slider { + width: 355px; - .tooltip { - // above everything - z-index: 20000; + .slider-tip { + display: inline-block; + font-size: 0.8125rem; + font-style: italic; + padding: 5px 10px; + } + + .options { + display: flex; + padding: 2px 0 10px; + + .column { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 7px; + + a { padding: 0 3px; } + } + } + + .rc-slider { + margin: 0 10px 8px; + width: auto; + } } } + .topologies { margin: 8px 4px; display: flex; @@ -279,10 +355,12 @@ opacity: 0.25; font-size: 320px; } + + li { padding-top: 5px; } } &-loading &-error-icon-container { - animation: blinking 2.0s infinite $base-ease; + @extend .blinkable; } &-loading { @@ -695,8 +773,8 @@ color: $white; &-icon { + @extend .blinkable; margin-right: 0.5em; - animation: blinking 2.0s infinite $base-ease; } } } @@ -1409,9 +1487,9 @@ .sidebar { position: fixed; - bottom: 12px; - left: 12px; - padding: 4px; + bottom: 11px; + left: 11px; + padding: 5px; font-size: .7rem; border-radius: 8px; border: 1px solid transparent; @@ -1760,27 +1838,16 @@ .zoom-control { @extend .overlay-wrapper; - align-items: center; flex-direction: column; - padding: 10px 10px 5px; + padding: 5px 7px 0; bottom: 50px; right: 40px; - .zoom-in, .zoom-out { - @extend .btn-opacity; - color: $text-secondary-color; - cursor: pointer; - font-size: 150%; - } + a:hover { border-color: transparent; } .rc-slider { - margin: 10px 0; + margin: 5px 0; height: 60px; - - .rc-slider-step { cursor: pointer; } - .rc-slider-track { background-color: $text-tertiary-color; } - .rc-slider-rail { background-color: $border-light-color; } - .rc-slider-handle { border-color: $text-tertiary-color; } } }