From 2340ccc8fe3a51ed989e9800c5a518491e44609f Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 16 May 2017 11:39:31 +0100 Subject: [PATCH 01/21] Hacky working prototype. --- app/api_report.go | 4 +- app/api_topologies.go | 5 ++- app/api_topology.go | 11 +++++- app/collector.go | 10 +++-- app/multitenant/aws_collector.go | 19 ++++----- app/router.go | 3 +- client/app/scripts/actions/app-actions.js | 14 +++++++ client/app/scripts/components/app.js | 3 ++ .../scripts/components/timeline-control.js | 39 +++++++++++++++++++ client/app/scripts/reducers/root.js | 10 +++-- client/app/scripts/utils/web-api-utils.js | 22 ++++++++--- client/app/styles/_base.scss | 11 ++++++ 12 files changed, 123 insertions(+), 28 deletions(-) create mode 100644 client/app/scripts/components/timeline-control.js 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..cd5e9e2a5c 100644 --- a/app/api_topology.go +++ b/app/api_topology.go @@ -97,12 +97,21 @@ func handleWebsocket( tick = time.Tick(loop) wait = make(chan struct{}, 1) topologyID = mux.Vars(r)["topology"] + timestamp = time.Now() ) + + log.Debugf("BLUBLUBLUBLU") + if timestampStr := r.Form.Get("timestamp"); timestampStr != "" { + const ISO8601UTC = "2006-01-02T15:04:05Z" + timestamp, _ = time.Parse(ISO8601UTC, timestampStr) + log.Debugf("BLUBLUBLUBLU: %s %v", timestampStr, timestamp) + } + rep.WaitOn(ctx, wait) defer rep.UnWait(ctx, wait) for { - report, err := rep.Report(ctx) + report, err := rep.Report(ctx, timestamp) if err != nil { log.Errorf("Error generating report: %v", err) return diff --git a/app/collector.go b/app/collector.go index 659fe242ad..8a465605ee 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, moment 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 := moment.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/multitenant/aws_collector.go b/app/multitenant/aws_collector.go index d0810368bc..3ccb6b7a1b 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, pointInTime 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 = pointInTime + 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/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d3c5c767f9..ce6e036328 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -217,6 +217,7 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { getNodesDelta( getCurrentTopologyUrl(state), activeTopologyOptionsSelector(state), + state.get('topologyTimestamp'), dispatch ); getNodeDetails( @@ -411,6 +412,7 @@ function updateTopology(dispatch, getState) { getNodesDelta( getCurrentTopologyUrl(state), activeTopologyOptionsSelector(state), + state.get('topologyTimestamp'), dispatch ); } @@ -436,6 +438,16 @@ export function clickTopology(topologyId) { }; } +export function changeTopologyTimestamp(timestamp) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_TOPOLOGY, + timestamp + }); + updateTopology(dispatch, getState); + }; +} + export function cacheZoomState(zoomState) { return { type: ActionTypes.CACHE_ZOOM_STATE, @@ -623,6 +635,7 @@ export function receiveTopologies(topologies) { getNodesDelta( getCurrentTopologyUrl(state), activeTopologyOptionsSelector(state), + state.get('topologyTimestamp'), dispatch ); getNodeDetails( @@ -744,6 +757,7 @@ export function route(urlState) { getNodesDelta( getCurrentTopologyUrl(state), activeTopologyOptionsSelector(state), + state.get('topologyTimestamp'), dispatch ); getNodeDetails( diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0d59a68edf..7aa6ff2ae5 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -29,6 +29,7 @@ import { } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; +import TimelineControl from './timeline-control'; import ViewModeSelector from './view-mode-selector'; import NetworkSelector from './networks-selector'; import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar'; @@ -190,6 +191,8 @@ class App extends React.Component { + + {showingNetworkSelector && isGraphViewMode && } {!isResourceViewMode && } diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js new file mode 100644 index 0000000000..4f6e50f521 --- /dev/null +++ b/client/app/scripts/components/timeline-control.js @@ -0,0 +1,39 @@ +import React from 'react'; +import moment from 'moment'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; + +import { changeTopologyTimestamp } from '../actions/app-actions'; + + +class TimelineControl extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = { value: moment().toISOString() }; + this.handleChange = this.handleChange.bind(this); + this.queryTopology = debounce(this.queryTopology.bind(this), 2000); + } + + queryTopology() { + console.log('QUERY TOPOLOGY AT: ', this.state.value); + this.props.changeTopologyTimestamp(this.state.value); + } + + handleChange(ev) { + const value = ev.target.value; + this.setState({ value }); + this.queryTopology(); + } + + render() { + return ( +
+ +
+ ); + } +} + + +export default connect(null, { changeTopologyTimestamp })(TimelineControl); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 3f731a8287..3f392b1fec 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -122,7 +122,10 @@ function processTopologies(state, nextTopologies) { return state.mergeDeepIn(['topologies'], immNextTopologies); } -function setTopology(state, topologyId) { +function setTopology(state, topologyId, timestamp) { + if (timestamp) { + state = state.set('topologyTimestamp', timestamp); + } state = state.set('currentTopology', findTopologyById(state.get('topologies'), topologyId)); return state.set('currentTopologyId', topologyId); } @@ -331,8 +334,9 @@ export function rootReducer(state = initialState, action) { state = resumeUpdate(state); state = closeAllNodeDetails(state); - if (action.topologyId !== state.get('currentTopologyId')) { - state = setTopology(state, action.topologyId); + const currentTopologyId = state.get('currentTopologyId'); + if (action.topologyId !== currentTopologyId || action.timestamp) { + state = setTopology(state, action.topologyId || currentTopologyId, action.timestamp); state = clearNodes(state); } diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 41e9fa7e29..c763c03e9f 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -36,6 +36,7 @@ let socket; let reconnectTimer = 0; let currentUrl = null; let currentOptions = null; +let currentTimestamp = null; let topologyTimer = 0; let apiDetailsTimer = 0; let controlErrorTimer = 0; @@ -93,7 +94,7 @@ 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 createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -106,7 +107,14 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) { createWebsocketAt = new Date(); firstMessageOnWebsocketAt = 0; - socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`); + let options = `t=${updateFrequency}`; + if (optionsQuery) { + options = `${options}&${optionsQuery}`; + } + if (timestamp) { + options = `${options}×tamp=${timestamp}`; + } + socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?${options}`); socket.onopen = () => { dispatch(openWebsocket()); @@ -120,7 +128,7 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) { if (continuePolling) { reconnectTimer = setTimeout(() => { - createWebsocket(topologyUrl, optionsQuery, dispatch); + createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch); }, reconnectTimerInterval); } }; @@ -227,18 +235,20 @@ export function getTopologies(options, dispatch, initialPoll) { // 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) { +export function getNodesDelta(topologyUrl, options, timestamp, dispatch) { const optionsQuery = buildOptionsQuery(options); // 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 isNewTimestamp = timestamp && currentTimestamp !== timestamp; + const isNewUrl = topologyUrl !== currentUrl || isNewOptions || isNewTimestamp; // `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); + createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch); currentUrl = topologyUrl; currentOptions = optionsQuery; + currentTimestamp = timestamp; } } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 7588841df2..08d0fd42d6 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -197,6 +197,17 @@ } } +.timeline-control { + padding: 5px; + position: absolute; + bottom: 11px; + right: 500px; + color: $text-tertiary-color; + background-color: fade-out($background-average-color, .1); + font-size: 0.7rem; + display: flex; +} + .topologies { margin: 8px 4px; display: flex; From 602d83b8855d72ae87724f6cedd6197321becccb Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 29 May 2017 19:39:43 +0200 Subject: [PATCH 02/21] Operate with time.Duration offset instead of fixed timestamp. --- app/api_report.go | 4 +- app/api_topologies.go | 5 +- app/api_topology.go | 11 ++- app/collector.go | 8 +- app/multitenant/aws_collector.go | 4 +- app/router.go | 3 +- client/app/scripts/charts/nodes-layout.js | 2 +- .../app/scripts/components/editable-time.js | 90 ++++++++++++++++++ client/app/scripts/components/footer.js | 33 +------ .../scripts/components/timeline-control.js | 69 +++++++++----- client/app/styles/_base.scss | 94 +++++++++---------- client/app/styles/_variables.scss | 2 + 12 files changed, 203 insertions(+), 122 deletions(-) create mode 100644 client/app/scripts/components/editable-time.js diff --git a/app/api_report.go b/app/api_report.go index 6dfbd153dd..2420a60e1a 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, time.Now()) + report, err := rep.Report(ctx, 0) 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, time.Now()) + rpt, err := rep.Report(ctx, 0) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/app/api_topologies.go b/app/api_topologies.go index e2e7793608..ecff228ccf 100644 --- a/app/api_topologies.go +++ b/app/api_topologies.go @@ -7,7 +7,6 @@ import ( "sort" "strings" "sync" - "time" log "github.com/Sirupsen/logrus" "github.com/gorilla/mux" @@ -477,7 +476,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, time.Now()) + report, err := rep.Report(ctx, 0) if err != nil { respondWith(w, http.StatusInternalServerError, err) return @@ -569,7 +568,7 @@ func (r *Registry) captureRenderer(rep Reporter, f rendererHandler) CtxHandlerFu http.NotFound(w, req) return } - rpt, err := rep.Report(ctx, time.Now()) + rpt, err := rep.Report(ctx, 0) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/app/api_topology.go b/app/api_topology.go index cd5e9e2a5c..43dda87f7a 100644 --- a/app/api_topology.go +++ b/app/api_topology.go @@ -100,18 +100,19 @@ func handleWebsocket( timestamp = time.Now() ) - log.Debugf("BLUBLUBLUBLU") if timestampStr := r.Form.Get("timestamp"); timestampStr != "" { - const ISO8601UTC = "2006-01-02T15:04:05Z" - timestamp, _ = time.Parse(ISO8601UTC, timestampStr) - log.Debugf("BLUBLUBLUBLU: %s %v", timestampStr, timestamp) + // Override the default current timestamp by the ISO8601 one explicitly provided by the UI. + timestamp, _ = time.Parse(time.RFC3339, timestampStr) } + // Use the time offset instead of a timestamp here so that the value + // can stay constant when simulating past reports (with normal speed). + timeOffset := time.Since(timestamp) rep.WaitOn(ctx, wait) defer rep.UnWait(ctx, wait) for { - report, err := rep.Report(ctx, timestamp) + report, err := rep.Report(ctx, timeOffset) if err != nil { log.Errorf("Error generating report: %v", err) return diff --git a/app/collector.go b/app/collector.go index 8a465605ee..9bd746cc5c 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, time.Time) (report.Report, error) + Report(context.Context, time.Duration) (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, moment time.Time) (report.Report, error) { +func (c *collector) Report(_ context.Context, timeOffset time.Duration) (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 := moment.Add(-c.window) + oldest := mtime.Now().Add(-timeOffset - c.window) if c.timestamps[0].After(oldest) { return *c.cached, nil } @@ -191,7 +191,7 @@ type StaticCollector report.Report // Report returns a merged report over all added reports. It implements // Reporter. -func (c StaticCollector) Report(context.Context, time.Time) (report.Report, error) { +func (c StaticCollector) Report(context.Context, time.Duration) (report.Report, error) { return report.Report(c), nil } diff --git a/app/multitenant/aws_collector.go b/app/multitenant/aws_collector.go index 3ccb6b7a1b..61c2896f9a 100644 --- a/app/multitenant/aws_collector.go +++ b/app/multitenant/aws_collector.go @@ -297,9 +297,9 @@ func (c *awsCollector) getReports(ctx context.Context, reportKeys []string) ([]r return reports, nil } -func (c *awsCollector) Report(ctx context.Context, pointInTime time.Time) (report.Report, error) { +func (c *awsCollector) Report(ctx context.Context, timeOffset time.Duration) (report.Report, error) { var ( - end = pointInTime + end = time.Now().Add(-timeOffset) start = end.Add(-c.window) rowStart = start.UnixNano() / time.Hour.Nanoseconds() rowEnd = end.UnixNano() / time.Hour.Nanoseconds() diff --git a/app/router.go b/app/router.go index 90480a0944..cfc8e93de9 100644 --- a/app/router.go +++ b/app/router.go @@ -9,7 +9,6 @@ import ( "net/url" "strings" "sync" - "time" "github.com/PuerkitoBio/ghost/handlers" log "github.com/Sirupsen/logrus" @@ -180,7 +179,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, time.Now()) + report, err := rep.Report(ctx, 0) if err != nil { respondWith(w, http.StatusInternalServerError, err) return diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index f061f83156..6cd8bf3e53 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -422,7 +422,7 @@ export function doLayout(immNodes, immEdges, opts) { const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions); // one engine and node and edge caches per topology, to keep renderings similar - if (options.noCache || !topologyCaches[cacheId]) { + if (true || options.noCache || !topologyCaches[cacheId]) { topologyCaches[cacheId] = { nodeCache: makeMap(), edgeCache: makeMap(), diff --git a/client/app/scripts/components/editable-time.js b/client/app/scripts/components/editable-time.js new file mode 100644 index 0000000000..62bc96ff34 --- /dev/null +++ b/client/app/scripts/components/editable-time.js @@ -0,0 +1,90 @@ +import React from 'react'; +import moment from 'moment'; +import { connect } from 'react-redux'; + +import { ENTER_KEY_CODE } from '../constants/key-codes'; +import { changeTopologyTimestamp } from '../actions/app-actions'; + + +class EditableTime extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = { + value: '', + editing: false, + timeOffset: 0, + }; + + this.handleFocus = this.handleFocus.bind(this); + this.handleBlur = this.handleBlur.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + this.handleChange = this.handleChange.bind(this); + this.saveInputRef = this.saveInputRef.bind(this); + } + + componentDidMount() { + this.timer = setInterval(() => { + if (!this.state.editing) { + this.setState(this.getFreshState()); + } + }, 100); + } + + componentWillUnmount() { + clearInterval(this.timer); + } + + handleFocus() { + this.setState({ editing: true }); + } + + handleBlur() { + this.setState({ editing: false }); + } + + handleChange(ev) { + this.setState({ value: ev.target.value }); + } + + handleKeyUp(ev) { + if (ev.keyCode === ENTER_KEY_CODE) { + const { value } = this.state; + const timeOffset = moment.duration(moment().diff(value)); + this.props.changeTopologyTimestamp(value); + + this.setState({ timeOffset }); + this.inputRef.blur(); + + console.log('QUERY TOPOLOGY AT: ', value, timeOffset); + } + } + + saveInputRef(ref) { + this.inputRef = ref; + } + + getFreshState() { + const { timeOffset } = this.state; + const time = moment().utc().subtract(timeOffset); + return { value: time.toISOString() }; + } + + render() { + return ( + + + + ); + } +} + +export default connect(null, { changeTopologyTimestamp })(EditableTime); 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}} - - 0; + const pauseTitle = isPaused ? + `Paused ${moment(this.props.updatePausedAt).fromNow()}` : + 'Pause updates (freezes the nodes in their current layout)'; + const pauseAction = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; + let pauseLabel = ''; + if (hasUpdates && isPaused) { + pauseLabel = `Paused +${updateCount}`; + } else if (hasUpdates && !isPaused) { + pauseLabel = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPaused) { + pauseLabel = 'Paused'; + } + return ( ); } } +function mapStateToProps(state) { + return { + updatePausedAt: state.get('updatePausedAt'), + }; +} -export default connect(null, { changeTopologyTimestamp })(TimelineControl); +export default connect( + mapStateToProps, + { + clickPauseUpdate, + clickResumeUpdate, + } +)(TimelineControl); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 08d0fd42d6..fde90441df 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -48,13 +48,39 @@ } .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; + + a { + @extend .btn-opacity; + border: 1px solid transparent; + border-radius: 4px; + color: $text-secondary-color; + cursor: pointer; + padding: 4px 3px; + + .fa { + font-size: 150%; + position: relative; + top: 2px; + } + + &:hover { + border: 1px solid $text-tertiary-color; + } + + .active { + border: 1px solid $text-tertiary-color; + animation: blinking 1.5s infinite $base-ease; + } + } } .btn-opacity { @@ -142,12 +168,6 @@ bottom: 11px; right: 43px; - a { - @extend .btn-opacity; - color: $text-secondary-color; - cursor: pointer; - } - &-status { margin-right: 1em; } @@ -164,31 +184,6 @@ &-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; - } - - .fa { - font-size: 150%; - position: relative; - top: 2px; - } - - &-active { - border: 1px solid $text-tertiary-color; - animation: blinking 1.5s infinite $base-ease; - } - } - - &-icon &-label { - margin-right: 0.5em; } .tooltip { @@ -198,14 +193,23 @@ } .timeline-control { - padding: 5px; - position: absolute; - bottom: 11px; - right: 500px; - color: $text-tertiary-color; - background-color: fade-out($background-average-color, .1); - font-size: 0.7rem; - display: flex; + @extend .overlay-wrapper; + left: calc(50vw - #{$timeline-control-width} / 2); + width: $timeline-control-width; + bottom: 0; + + + .status-info { + font-size: 125%; + + .fa { + margin-right: 5px; + font-size: 110%; + } + } + + .button { margin-left: 0.5em; } + input { margin: 0 8px; } } .topologies { @@ -1771,21 +1775,15 @@ .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; } diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss index 801f8738f4..345c6193cf 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -53,6 +53,8 @@ $link-opacity-default: 0.8; $search-border-color: transparent; $search-border-width: 1px; +$timeline-control-width: 285px; + /* specific elements */ $body-background-color: linear-gradient(30deg, $background-color 0%, $background-lighter-color 100%); $label-background-color: fade-out($background-average-color, .3); From 231ace7fc94f5d7812ca24daffca5efbad60fa62 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 30 May 2017 15:30:18 +0200 Subject: [PATCH 03/21] Polished the backend code. --- app/api_report.go | 4 ++-- app/api_topologies.go | 5 +++-- app/api_topology.go | 29 ++++++++++++++++++----------- app/collector.go | 8 ++++---- app/multitenant/aws_collector.go | 4 ++-- app/router.go | 3 ++- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/app/api_report.go b/app/api_report.go index 2420a60e1a..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, 0) + 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, 0) + 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 ecff228ccf..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, 0) + 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, 0) + 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 43dda87f7a..e0c8e2f5a5 100644 --- a/app/api_topology.go +++ b/app/api_topology.go @@ -93,26 +93,33 @@ func handleWebsocket( }(conn) var ( - previousTopo detailed.NodeSummaries - tick = time.Tick(loop) - wait = make(chan struct{}, 1) - topologyID = mux.Vars(r)["topology"] - timestamp = time.Now() + 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 != "" { - // Override the default current timestamp by the ISO8601 one explicitly provided by the UI. - timestamp, _ = time.Parse(time.RFC3339, timestampStr) + startReportingAt, _ = time.Parse(time.RFC3339, timestampStr) } - // Use the time offset instead of a timestamp here so that the value - // can stay constant when simulating past reports (with normal speed). - timeOffset := time.Since(timestamp) rep.WaitOn(ctx, wait) defer rep.UnWait(ctx, wait) for { - report, err := rep.Report(ctx, timeOffset) + // 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/collector.go b/app/collector.go index 9bd746cc5c..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, time.Duration) (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, timeOffset time.Duration) (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(-timeOffset - c.window) + oldest := timestamp.Add(-c.window) if c.timestamps[0].After(oldest) { return *c.cached, nil } @@ -191,7 +191,7 @@ type StaticCollector report.Report // Report returns a merged report over all added reports. It implements // Reporter. -func (c StaticCollector) Report(context.Context, time.Duration) (report.Report, error) { +func (c StaticCollector) Report(context.Context, time.Time) (report.Report, error) { return report.Report(c), nil } diff --git a/app/multitenant/aws_collector.go b/app/multitenant/aws_collector.go index 61c2896f9a..d7288d1bb9 100644 --- a/app/multitenant/aws_collector.go +++ b/app/multitenant/aws_collector.go @@ -297,9 +297,9 @@ func (c *awsCollector) getReports(ctx context.Context, reportKeys []string) ([]r return reports, nil } -func (c *awsCollector) Report(ctx context.Context, timeOffset time.Duration) (report.Report, error) { +func (c *awsCollector) Report(ctx context.Context, timestamp time.Time) (report.Report, error) { var ( - end = time.Now().Add(-timeOffset) + end = timestamp start = end.Add(-c.window) rowStart = start.UnixNano() / time.Hour.Nanoseconds() rowEnd = end.UnixNano() / time.Hour.Nanoseconds() diff --git a/app/router.go b/app/router.go index cfc8e93de9..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, 0) + report, err := rep.Report(ctx, time.Now()) if err != nil { respondWith(w, http.StatusInternalServerError, err) return From d1a315752e5fccfc865059ee2d7cdce31da9a9c7 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 31 May 2017 15:06:15 +0200 Subject: [PATCH 04/21] Made a nicer UI component. --- client/app/scripts/components/running-time.js | 34 +++ .../scripts/components/timeline-control.js | 223 +++++++++++++++++- client/app/styles/_base.scss | 64 ++++- client/app/styles/_variables.scss | 2 +- 4 files changed, 297 insertions(+), 26 deletions(-) create mode 100644 client/app/scripts/components/running-time.js diff --git a/client/app/scripts/components/running-time.js b/client/app/scripts/components/running-time.js new file mode 100644 index 0000000000..2d4affb524 --- /dev/null +++ b/client/app/scripts/components/running-time.js @@ -0,0 +1,34 @@ +import React from 'react'; +import moment from 'moment'; + + +export default class RunningTime extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = this.getFreshState(); + } + + componentDidMount() { + this.timer = setInterval(() => { + if (!this.props.paused) { + this.setState(this.getFreshState()); + } + }, 500); + } + + componentWillUnmount() { + clearInterval(this.timer); + } + + getFreshState() { + const timestamp = moment().utc().subtract(this.props.offsetMilliseconds); + return { humanizedTimestamp: timestamp.format('MMMM Do YYYY, h:mm:ss a') }; + } + + render() { + return ( + {this.state.humanizedTimestamp} + ); + } +} diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index cb339e966b..b919b7c8e2 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -1,17 +1,176 @@ 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 EditableTime from './editable-time'; +import RunningTime from './running-time'; import { getUpdateBufferSize } from '../utils/update-buffer-utils'; import { clickPauseUpdate, clickResumeUpdate, + changeTopologyTimestamp, } from '../actions/app-actions'; +const sliderRanges = { + last15Minutes: { + label: 'Last 15 minutes', + getStart: () => moment().utc().subtract(15, 'minutes'), + getEnd: () => moment().utc(), + }, + last1Hour: { + label: 'Last 1 hour', + getStart: () => moment().utc().subtract(1, 'hour'), + getEnd: () => moment().utc(), + }, + last6Hours: { + label: 'Last 6 hours', + getStart: () => moment().utc().subtract(6, 'hours'), + getEnd: () => moment().utc(), + }, + last24Hours: { + label: 'Last 24 hours', + getStart: () => moment().utc().subtract(24, 'hours'), + getEnd: () => moment().utc(), + }, + last7Days: { + label: 'Last 7 days', + getStart: () => moment().utc().subtract(7, 'days'), + getEnd: () => moment().utc(), + }, + last30Days: { + label: 'Last 30 days', + getStart: () => moment().utc().subtract(30, 'days'), + getEnd: () => moment().utc(), + }, + last90Days: { + label: 'Last 90 days', + getStart: () => moment().utc().subtract(90, 'days'), + getEnd: () => moment().utc(), + }, + last1Year: { + label: 'Last 1 year', + getStart: () => moment().subtract(1, 'year'), + getEnd: () => moment().utc(), + }, + todaySoFar: { + label: 'Today so far', + getStart: () => moment().utc().startOf('day'), + getEnd: () => moment().utc(), + }, + thisWeekSoFar: { + label: 'This week so far', + getStart: () => moment().utc().startOf('week'), + getEnd: () => moment().utc(), + }, + thisMonthSoFar: { + label: 'This month so far', + getStart: () => moment().utc().startOf('month'), + getEnd: () => moment().utc(), + }, + thisYearSoFar: { + label: 'This year so far', + getStart: () => moment().utc().startOf('year'), + getEnd: () => moment().utc(), + }, + yesterday: { + label: 'Yesterday', + getStart: () => moment().utc().subtract(1, 'day').startOf('day'), + getEnd: () => moment().utc().subtract(1, 'day').endOf('day'), + }, + previousWeek: { + label: 'Previous week', + getStart: () => moment().utc().subtract(1, 'week').startOf('week'), + getEnd: () => moment().utc().subtract(1, 'week').endOf('week'), + }, + previousMonth: { + label: 'Previous month', + getStart: () => moment().utc().subtract(1, 'month').startOf('month'), + getEnd: () => moment().utc().subtract(1, 'month').endOf('month'), + }, + previousYear: { + label: 'Previous year', + getStart: () => moment().utc().subtract(1, 'year').startOf('year'), + getEnd: () => moment().utc().subtract(1, 'year').endOf('year'), + }, +}; + class TimelineControl extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = { + showTimelinePanel: false, + offsetMilliseconds: 0, + rangeOptionSelected: sliderRanges.last1Hour, + }; + + this.jumpToNow = this.jumpToNow.bind(this); + this.toggleTimelinePanel = this.toggleTimelinePanel.bind(this); + this.handleSliderChange = this.handleSliderChange.bind(this); + this.renderRangeOption = this.renderRangeOption.bind(this); + this.debouncedUpdateTimestamp = debounce(this.updateTimestamp.bind(this), 500); + } + + updateTimestamp(timestamp) { + timestamp = timestamp || moment(); + this.props.changeTopologyTimestamp(timestamp.toISOString()); + console.log(timestamp.toISOString()); + } + + toggleTimelinePanel() { + this.setState({ showTimelinePanel: !this.state.showTimelinePanel }); + } + + handleSliderChange(value) { + const offsetMilliseconds = this.getRangeMilliseconds() - value; + const timestamp = moment().utc().subtract(offsetMilliseconds); + this.debouncedUpdateTimestamp(timestamp); + this.setState({ offsetMilliseconds }); + } + + getRangeMilliseconds() { + const range = this.state.rangeOptionSelected; + return range.getEnd().diff(range.getStart()); + } + + jumpToNow() { + this.setState({ + showTimelinePanel: false, + offsetMilliseconds: 0, + rangeOptionSelected: sliderRanges.last1Hour, + }); + this.updateTimestamp(); + } + + renderRangeOption(option) { + const handleClick = () => { this.setState({ rangeOptionSelected: option }); }; + const selected = (this.state.rangeOptionSelected.label === option.label); + const className = classNames('option', { selected }); + + return ( + + {option.label} + + ); + } + + getTotalOffset() { + const { rangeOptionSelected, offsetMilliseconds } = this.state; + const rangeBehindMilliseconds = moment().diff(rangeOptionSelected.getEnd()); + return offsetMilliseconds + rangeBehindMilliseconds; + } + render() { + const { showTimelinePanel, offsetMilliseconds } = this.state; + const rangeMilliseconds = this.getRangeMilliseconds(); + + const showingCurrent = (this.getTotalOffset() === 0); + const timeStatusClassName = classNames('time-status', { 'showing-current': showingCurrent }); + const toggleButtonClassName = classNames('button toggle', { selected: showTimelinePanel }); + // pause button const isPaused = this.props.updatePausedAt !== null; const updateCount = getUpdateBufferSize(); @@ -31,17 +190,56 @@ class TimelineControl extends React.PureComponent { return (
- - - - {false && } - - - - - {pauseLabel !== '' && {pauseLabel}} - - + {showTimelinePanel &&
+ Explore +
+
+ {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)} +
+
+ {this.renderRangeOption(sliderRanges.yesterday)} + {this.renderRangeOption(sliderRanges.previousWeek)} + {this.renderRangeOption(sliderRanges.previousMonth)} + {this.renderRangeOption(sliderRanges.previousYear)} +
+
+ +
} +
); } @@ -58,5 +256,6 @@ export default connect( { clickPauseUpdate, clickResumeUpdate, + changeTopologyTimestamp, } )(TimelineControl); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index fde90441df..873c16e7c1 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -72,7 +72,7 @@ top: 2px; } - &:hover { + &:hover, &.selected { border: 1px solid $text-tertiary-color; } @@ -163,6 +163,14 @@ } } + +.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; @@ -194,24 +202,59 @@ .timeline-control { @extend .overlay-wrapper; + display: block; left: calc(50vw - #{$timeline-control-width} / 2); width: $timeline-control-width; bottom: 0; + .time-status { + display: flex; + align-items: center; + padding: 0 9px; - .status-info { - font-size: 125%; + .running-time { font-size: 115%; } + .button { margin-left: 0.5em; } - .fa { - margin-right: 5px; - font-size: 110%; + .jump-to-now { + animation: blinking 1.5s infinite $base-ease; + margin-left: auto; + } + + &:not(.showing-current) { + .running-time { font-weight: bold; } } } - .button { margin-left: 0.5em; } - input { margin: 0 8px; } + .timeline-panel { + strong { + display: inline-block; + font-size: 0.8125rem; + font-weight: normal; + 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 3px; + width: auto; + } + } } + .topologies { margin: 8px 4px; display: flex; @@ -1785,11 +1828,6 @@ .rc-slider { 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; } } } diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss index 345c6193cf..e60fe51316 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -53,7 +53,7 @@ $link-opacity-default: 0.8; $search-border-color: transparent; $search-border-width: 1px; -$timeline-control-width: 285px; +$timeline-control-width: 405px; /* specific elements */ $body-background-color: linear-gradient(30deg, $background-color 0%, $background-lighter-color 100%); From 84addcaf22bc5de83d3a8f80cfdfc469df056462 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 31 May 2017 22:41:39 +0200 Subject: [PATCH 05/21] Small refactorings of the websockets code. --- client/app/scripts/actions/app-actions.js | 31 +------ .../app/scripts/components/editable-time.js | 90 ------------------ client/app/scripts/reducers/root.js | 10 +- .../utils/__tests__/web-api-utils-test.js | 8 +- client/app/scripts/utils/web-api-utils.js | 93 +++++++++---------- 5 files changed, 63 insertions(+), 169 deletions(-) delete mode 100644 client/app/scripts/components/editable-time.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index ce6e036328..ed96f90262 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -13,14 +13,13 @@ import { doControlRequest, getAllNodes, getResourceViewNodesSnapshot, - getNodesDelta, + updateNodesDeltaChannel, 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 { @@ -214,12 +213,7 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { resetUpdateBuffer(); const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - state.get('topologyTimestamp'), - dispatch - ); + updateNodesDeltaChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -409,12 +403,7 @@ function updateTopology(dispatch, getState) { // 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), - state.get('topologyTimestamp'), - dispatch - ); + updateNodesDeltaChannel(state, dispatch); } export function clickShowTopologyForNode(topologyId, nodeId) { @@ -632,12 +621,7 @@ export function receiveTopologies(topologies) { topologies }); const state = getState(); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - state.get('topologyTimestamp'), - dispatch - ); + updateNodesDeltaChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -754,12 +738,7 @@ export function route(urlState) { // update all request workers with new options const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - getNodesDelta( - getCurrentTopologyUrl(state), - activeTopologyOptionsSelector(state), - state.get('topologyTimestamp'), - dispatch - ); + updateNodesDeltaChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), diff --git a/client/app/scripts/components/editable-time.js b/client/app/scripts/components/editable-time.js deleted file mode 100644 index 62bc96ff34..0000000000 --- a/client/app/scripts/components/editable-time.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import { connect } from 'react-redux'; - -import { ENTER_KEY_CODE } from '../constants/key-codes'; -import { changeTopologyTimestamp } from '../actions/app-actions'; - - -class EditableTime extends React.PureComponent { - constructor(props, context) { - super(props, context); - - this.state = { - value: '', - editing: false, - timeOffset: 0, - }; - - this.handleFocus = this.handleFocus.bind(this); - this.handleBlur = this.handleBlur.bind(this); - this.handleKeyUp = this.handleKeyUp.bind(this); - this.handleChange = this.handleChange.bind(this); - this.saveInputRef = this.saveInputRef.bind(this); - } - - componentDidMount() { - this.timer = setInterval(() => { - if (!this.state.editing) { - this.setState(this.getFreshState()); - } - }, 100); - } - - componentWillUnmount() { - clearInterval(this.timer); - } - - handleFocus() { - this.setState({ editing: true }); - } - - handleBlur() { - this.setState({ editing: false }); - } - - handleChange(ev) { - this.setState({ value: ev.target.value }); - } - - handleKeyUp(ev) { - if (ev.keyCode === ENTER_KEY_CODE) { - const { value } = this.state; - const timeOffset = moment.duration(moment().diff(value)); - this.props.changeTopologyTimestamp(value); - - this.setState({ timeOffset }); - this.inputRef.blur(); - - console.log('QUERY TOPOLOGY AT: ', value, timeOffset); - } - } - - saveInputRef(ref) { - this.inputRef = ref; - } - - getFreshState() { - const { timeOffset } = this.state; - const time = moment().utc().subtract(timeOffset); - return { value: time.toISOString() }; - } - - render() { - return ( - - - - ); - } -} - -export default connect(null, { changeTopologyTimestamp })(EditableTime); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 3f392b1fec..d3b8d6ba13 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -1,8 +1,14 @@ /* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */ import debug from 'debug'; 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 { 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/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index c763c03e9f..685b362d87 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,7 +1,7 @@ import debug from 'debug'; import reqwest from 'reqwest'; import defaults from 'lodash/defaults'; -import { Map as makeMap, List } from 'immutable'; +import { fromJS, Map as makeMap, List } from 'immutable'; import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError, receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError, @@ -9,7 +9,9 @@ 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 { activeTopologyOptionsSelector } from '../selectors/topology'; import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer'; const log = debug('scope:web-api-utils'); @@ -34,26 +36,24 @@ const csrfToken = (() => { let socket; let reconnectTimer = 0; -let currentUrl = null; -let currentOptions = null; -let currentTimestamp = 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 ''; + + return params.map((value, param) => { + if (value === undefined) return null; + if (List.isList(value)) { + value = value.join(','); + } + return `${param}=${value}`; + }).filter(s => s).join('&'); } export function basePath(urlPath) { @@ -94,7 +94,17 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l return `${wsProto}://${host}${process.env.SCOPE_API_PREFIX || ''}${basePath(pathname)}`; } -function createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch) { +function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) { + const query = buildUrlQuery(fromJS({ + t: updateFrequency, + timestamp: queryTimestamp, + ...topologyOptions.toJS(), + })); + console.log(query); + return `${getWebsocketUrl()}${topologyUrl}/ws?${query}`; +} + +function createWebsocket(websocketUrl, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -105,16 +115,9 @@ function createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch) { // profiling createWebsocketAt = new Date(); - firstMessageOnWebsocketAt = 0; + firstMessageOnWebsocketAt = null; - let options = `t=${updateFrequency}`; - if (optionsQuery) { - options = `${options}&${optionsQuery}`; - } - if (timestamp) { - options = `${options}×tamp=${timestamp}`; - } - socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?${options}`); + socket = new WebSocket(websocketUrl); socket.onopen = () => { dispatch(openWebsocket()); @@ -122,20 +125,20 @@ function createWebsocket(topologyUrl, optionsQuery, timestamp, dispatch) { 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, timestamp, 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) => { @@ -178,7 +181,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))), @@ -208,7 +211,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, @@ -233,22 +236,18 @@ export function getTopologies(options, dispatch, initialPoll) { }); } -// 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, timestamp, dispatch) { - const optionsQuery = buildOptionsQuery(options); +export function updateNodesDeltaChannel(state, dispatch) { + const topologyUrl = getCurrentTopologyUrl(state); + const topologyOptions = activeTopologyOptionsSelector(state); + const queryTimestamp = state.get('topologyTimestamp'); + 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 isNewTimestamp = timestamp && currentTimestamp !== timestamp; - const isNewUrl = topologyUrl !== currentUrl || isNewOptions || isNewTimestamp; + 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, timestamp, dispatch); - currentUrl = topologyUrl; - currentOptions = optionsQuery; - currentTimestamp = timestamp; + if (topologyUrl && (!socket || isNewUrl)) { + createWebsocket(websocketUrl, dispatch); + currentUrl = websocketUrl; } } @@ -260,7 +259,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(''); @@ -417,6 +416,6 @@ export function teardownWebsockets() { socket.onopen = null; socket.close(); socket = null; - currentOptions = null; + currentUrl = null; } } From 8d6b6ad2dcf5231d01966ae98612cba643a03e22 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 1 Jun 2017 15:01:27 +0200 Subject: [PATCH 06/21] Fixed the backend tests. --- app/benchmark_internal_test.go | 3 +- app/collector_test.go | 29 +++++++++++++++---- app/router_test.go | 2 +- .../scripts/selectors/resource-view/layout.js | 6 ++-- 4 files changed, 29 insertions(+), 11 deletions(-) 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_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/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/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; From 6246fd347763e95bea475828c9d53709484b9aac Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 1 Jun 2017 17:13:19 +0200 Subject: [PATCH 07/21] Better websocketing and smoother transitions --- client/app/scripts/actions/app-actions.js | 18 +++++++--- client/app/scripts/components/nodes.js | 8 +++-- .../scripts/components/timeline-control.js | 19 ++++++---- client/app/scripts/constants/action-types.js | 2 ++ client/app/scripts/constants/timer.js | 1 + client/app/scripts/reducers/root.js | 35 +++++++++++-------- client/app/scripts/utils/web-api-utils.js | 16 ++++++--- client/app/styles/_base.scss | 6 ++++ 8 files changed, 73 insertions(+), 32 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index ed96f90262..d7e09c888c 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,5 +1,5 @@ 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'; @@ -427,13 +427,21 @@ export function clickTopology(topologyId) { }; } -export function changeTopologyTimestamp(timestamp) { +export function moveInTime() { + return { + type: ActionTypes.MOVE_IN_TIME, + }; +} + +export function jumpToTimestamp(timestamp) { return (dispatch, getState) => { dispatch({ - type: ActionTypes.CLICK_TOPOLOGY, - timestamp + type: ActionTypes.JUMP_TO_TIMESTAMP, + timestamp: timestamp.toISOString(), }); - updateTopology(dispatch, getState); + updateNodesDeltaChannel(getState(), dispatch); + // update all request workers with new options + resetUpdateBuffer(); }; } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index ffa83a54dd..a442417ab5 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import { connect } from 'react-redux'; import NodesChart from '../charts/nodes-chart'; @@ -32,11 +33,13 @@ const EmptyTopologyError = show => ( class Nodes extends React.Component { render() { const { topologyEmpty, topologiesLoaded, nodesLoaded, topologies, currentTopology, - isGraphViewMode, isTableViewMode, isResourceViewMode } = this.props; + isGraphViewMode, isTableViewMode, isResourceViewMode, blurred } = this.props; + + const className = classNames('nodes-wrapper', { blurred }); // TODO: Rename view mode components. return ( -
+
nodes.clear()); - state = state.set('websocketClosed', false); - return state; + return state.set('websocketClosed', false); } case ActionTypes.DO_CONTROL_ERROR: { @@ -587,6 +588,12 @@ export function rootReducer(state = initialState, action) { 'add', size(action.delta.add)); } + console.log('RECEIVE DELTA', state.get('websocketMovingInTime')); + if (state.get('websocketMovingInTime')) { + state = state.set('websocketMovingInTime', false); + state = clearNodes(state); + } + state = state.set('errorUrl', null); // nodes that no longer exist diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 685b362d87..3072ff0574 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,6 +1,7 @@ import debug from 'debug'; import reqwest from 'reqwest'; -import defaults from 'lodash/defaults'; +import moment from 'moment'; +import { defaults } from 'lodash'; import { fromJS, Map as makeMap, List } from 'immutable'; import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError, @@ -48,7 +49,7 @@ export function buildUrlQuery(params) { if (!params) return ''; return params.map((value, param) => { - if (value === undefined) return null; + if (value === undefined || value === null) return null; if (List.isList(value)) { value = value.join(','); } @@ -95,16 +96,23 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l } function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) { + // If the timestamp stands for a time less than one second ago, + // assume we are actually interested in the current time. + if (moment().diff(moment(queryTimestamp)) < 1000) { + queryTimestamp = null; + } + const query = buildUrlQuery(fromJS({ t: updateFrequency, timestamp: queryTimestamp, ...topologyOptions.toJS(), })); - console.log(query); return `${getWebsocketUrl()}${topologyUrl}/ws?${query}`; } function createWebsocket(websocketUrl, dispatch) { + console.log('CREATING WEBSOCKET', websocketUrl); + if (socket) { socket.onclose = null; socket.onerror = null; @@ -239,7 +247,7 @@ export function getTopologies(options, dispatch, initialPoll) { export function updateNodesDeltaChannel(state, dispatch) { const topologyUrl = getCurrentTopologyUrl(state); const topologyOptions = activeTopologyOptionsSelector(state); - const queryTimestamp = state.get('topologyTimestamp'); + const queryTimestamp = state.get('websocketQueryTimestamp'); const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp); // Only recreate websocket if url changed or if forced (weave cloud instance reload); const isNewUrl = websocketUrl !== currentUrl; diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 873c16e7c1..31802b51ff 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -200,6 +200,12 @@ } } +.nodes-wrapper { + @extend .hideable; + + &.blurred { opacity: 0.2; } +} + .timeline-control { @extend .overlay-wrapper; display: block; From 6f744090a2faf8efef662f846f1034a5ada7f1f4 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 1 Jun 2017 21:11:41 +0200 Subject: [PATCH 08/21] Small styling refactoring. --- .../app/scripts/components/timeline-control.js | 2 +- .../app/scripts/reducers/__tests__/root-test.js | 2 -- client/app/styles/_base.scss | 17 +++++++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index eb4613293b..b998c1517c 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -195,7 +195,7 @@ class TimelineControl extends React.PureComponent { return (
{showTimelinePanel &&
- Explore + Move the slider to explore
{this.renderRangeOption(sliderRanges.last15Minutes)} diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index cfd2107ab4..c408e5ec47 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -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 diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 31802b51ff..4f7bd474db 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; } @@ -77,8 +81,8 @@ } .active { + @extend .blinkable; border: 1px solid $text-tertiary-color; - animation: blinking 1.5s infinite $base-ease; } } } @@ -222,12 +226,12 @@ .button { margin-left: 0.5em; } .jump-to-now { - animation: blinking 1.5s infinite $base-ease; + @extend .blinkable; margin-left: auto; } - &:not(.showing-current) { - .running-time { font-weight: bold; } + &:not(.showing-current) .running-time { + font-weight: bold; } } @@ -237,6 +241,7 @@ font-size: 0.8125rem; font-weight: normal; padding: 5px 10px; + text-decoration: underline; } .options { @@ -346,7 +351,7 @@ } &-loading &-error-icon-container { - animation: blinking 2.0s infinite $base-ease; + @extend .blinkable; } &-loading { @@ -759,8 +764,8 @@ color: $white; &-icon { + @extend .blinkable; margin-right: 0.5em; - animation: blinking 2.0s infinite $base-ease; } } } From 75f65a176167f8205405142e0a3871d9f385c26b Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Fri, 2 Jun 2017 11:43:22 +0200 Subject: [PATCH 09/21] Detecting empty topologies. --- client/app/scripts/actions/app-actions.js | 15 ++++++++----- .../scripts/components/timeline-control.js | 14 ++++++------ client/app/scripts/constants/action-types.js | 4 ++-- client/app/scripts/reducers/root.js | 22 +++++++------------ client/app/scripts/utils/web-api-utils.js | 3 +-- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d7e09c888c..cd35076b7a 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -427,16 +427,16 @@ export function clickTopology(topologyId) { }; } -export function moveInTime() { +export function startMovingInTime() { return { - type: ActionTypes.MOVE_IN_TIME, + type: ActionTypes.START_MOVING_IN_TIME, }; } -export function jumpToTimestamp(timestamp) { +export function websocketQueryTimestamp(timestamp) { return (dispatch, getState) => { dispatch({ - type: ActionTypes.JUMP_TO_TIMESTAMP, + type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, timestamp: timestamp.toISOString(), }); updateNodesDeltaChannel(getState(), dispatch); @@ -599,8 +599,11 @@ export function receiveNodesDelta(delta) { // setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); - if (delta.add || delta.update || delta.remove) { - const state = getState(); + const state = getState(); + const movingInTime = state.get('websocketMovingInTime'); + const hasChanges = delta.add || delta.update || delta.remove; + + if (hasChanges || movingInTime) { if (state.get('updatePausedAt') !== null) { bufferDeltaUpdate(delta); } else { diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index b998c1517c..30031654f2 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -10,8 +10,8 @@ import { getUpdateBufferSize } from '../utils/update-buffer-utils'; import { clickPauseUpdate, clickResumeUpdate, - jumpToTimestamp, - moveInTime, + websocketQueryTimestamp, + startMovingInTime, } from '../actions/app-actions'; import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; @@ -119,7 +119,7 @@ class TimelineControl extends React.PureComponent { } updateTimestamp(timestamp) { - this.props.jumpToTimestamp(timestamp); + this.props.websocketQueryTimestamp(timestamp); } toggleTimelinePanel() { @@ -129,7 +129,7 @@ class TimelineControl extends React.PureComponent { handleSliderChange(value) { const offsetMilliseconds = this.getRangeMilliseconds() - value; const timestamp = moment().utc().subtract(offsetMilliseconds); - this.props.moveInTime(); + this.props.startMovingInTime(); this.debouncedUpdateTimestamp(timestamp); this.setState({ offsetMilliseconds }); } @@ -145,7 +145,7 @@ class TimelineControl extends React.PureComponent { offsetMilliseconds: 0, rangeOptionSelected: sliderRanges.last1Hour, }); - this.props.moveInTime(); + this.props.startMovingInTime(); this.updateTimestamp(moment()); } @@ -260,7 +260,7 @@ export default connect( { clickPauseUpdate, clickResumeUpdate, - jumpToTimestamp, - moveInTime, + websocketQueryTimestamp, + startMovingInTime, } )(TimelineControl); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index d597e132a1..f2e818c91b 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -18,8 +18,8 @@ const ACTION_TYPES = [ 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', - 'MOVE_IN_TIME', - 'JUMP_TO_TIMESTAMP', + 'START_MOVING_IN_TIME', + 'WEBSOCKET_QUERY_TIMESTAMP', 'DEBUG_TOOLBAR_INTERFERING', 'DESELECT_NODE', 'DO_CONTROL', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 8ca9757934..869bf90623 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -348,11 +348,11 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.MOVE_IN_TIME: { + case ActionTypes.START_MOVING_IN_TIME: { return state.set('websocketMovingInTime', true); } - case ActionTypes.JUMP_TO_TIMESTAMP: { + case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { return state.set('websocketQueryTimestamp', action.timestamp); } @@ -578,24 +578,18 @@ 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); - console.log('RECEIVE DELTA', state.get('websocketMovingInTime')); if (state.get('websocketMovingInTime')) { state = state.set('websocketMovingInTime', false); state = clearNodes(state); } - state = state.set('errorUrl', null); - // nodes that no longer exist each(action.delta.remove, (nodeId) => { // in case node disappears before mouseleave event diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 3072ff0574..54d0274640 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -111,8 +111,6 @@ function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimest } function createWebsocket(websocketUrl, dispatch) { - console.log('CREATING WEBSOCKET', websocketUrl); - if (socket) { socket.onclose = null; socket.onerror = null; @@ -128,6 +126,7 @@ function createWebsocket(websocketUrl, dispatch) { socket = new WebSocket(websocketUrl); socket.onopen = () => { + log(`Opening websocket to ${websocketUrl}`); dispatch(openWebsocket()); }; From 7147a9c400909c9d9c49685a0635d6c3d27d39ff Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Fri, 2 Jun 2017 14:03:19 +0200 Subject: [PATCH 10/21] Improved error messaging. --- client/app/scripts/actions/app-actions.js | 11 +++- client/app/scripts/components/app.js | 2 +- client/app/scripts/components/nodes.js | 62 +++++++++++++------ client/app/scripts/components/status.js | 17 +++-- .../scripts/components/timeline-control.js | 4 ++ client/app/scripts/constants/timer.js | 6 +- client/app/scripts/utils/topology-utils.js | 19 ++++-- client/app/scripts/utils/web-api-utils.js | 17 ++--- client/app/styles/_base.scss | 2 + 9 files changed, 94 insertions(+), 46 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index cd35076b7a..2568bf9e26 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,4 +1,5 @@ import debug from 'debug'; +import moment from 'moment'; import { find } from 'lodash'; import ActionTypes from '../constants/action-types'; @@ -434,10 +435,18 @@ export function startMovingInTime() { } export function websocketQueryTimestamp(timestamp) { + // If the timestamp stands for a time less than one second ago, + // assume we are actually interested in the current time. + if (timestamp && moment().diff(timestamp) >= 1000) { + timestamp = timestamp.toISOString(); + } else { + timestamp = null; + } + return (dispatch, getState) => { dispatch({ type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - timestamp: timestamp.toISOString(), + timestamp, }); updateNodesDeltaChannel(getState(), dispatch); // update all request workers with new options diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 7aa6ff2ae5..0dc389be65 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -191,7 +191,7 @@ class App extends React.Component { - + {!isResourceViewMode && } {showingNetworkSelector && isGraphViewMode && } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index a442417ab5..a8989e93d8 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -8,45 +8,67 @@ import NodesResources from '../components/nodes-resources'; import NodesError from '../charts/nodes-error'; import DelayedShow from '../utils/delayed-show'; import { Loading, getNodeType } from './loading'; -import { isTopologyEmpty } from '../utils/topology-utils'; +import { + isTopologyNodeCountZero, + isNodesDisplayEmpty, + isTopologyEmpty, +} from '../utils/topology-utils'; import { isGraphViewModeSelector, isTableViewModeSelector, isResourceViewModeSelector, } from '../selectors/topology'; +import { TOPOLOGY_LOADER_DELAY } from '../constants/timer'; + + +const NODE_COUNT_ZERO_CAUSES = [ + "We haven't received any reports from probes recently. Are the probes properly connected?", + "Containers view only: you're not running Docker, or you don't have any containers", +]; -const EmptyTopologyError = show => ( - +const NODES_DISPLAY_EMPTY_CAUSES = [ + "There are nodes, but they're currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.", +]; + +const renderCauses = causes => ( +
    + {causes.map(cause => ( +
  • {cause}
  • + ))} +
); class Nodes extends React.Component { + renderConditionalEmptyTopologyError() { + const { topologyNodeCountZero, nodesDisplayEmpty, topologyEmpty } = this.props; + + return ( + + ); + } + render() { - const { topologyEmpty, topologiesLoaded, nodesLoaded, topologies, currentTopology, - isGraphViewMode, isTableViewMode, isResourceViewMode, blurred } = this.props; + const { topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode, + isTableViewMode, isResourceViewMode, blurred } = this.props; const className = classNames('nodes-wrapper', { blurred }); // TODO: Rename view mode components. return (
- + - {EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} + + {topologiesLoaded && nodesLoaded && this.renderConditionalEmptyTopologyError()} {isGraphViewMode && } {isTableViewMode && } @@ -62,12 +84,14 @@ function mapStateToProps(state) { isGraphViewMode: isGraphViewModeSelector(state), isTableViewMode: isTableViewModeSelector(state), isResourceViewMode: isResourceViewModeSelector(state), + topologyNodeCountZero: isTopologyNodeCountZero(state), + nodesDisplayEmpty: isNodesDisplayEmpty(state), + topologyEmpty: isTopologyEmpty(state), blurred: state.get('websocketMovingInTime'), currentTopology: state.get('currentTopology'), - nodesLoaded: state.get('nodesLoaded'), + nodesLoaded: state.get('nodesLoaded') || state.get('websocketMovingInTime'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), - topologyEmpty: isTopologyEmpty(state), }; } diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index c595b7bce9..9d83bb5de8 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,9 +1,11 @@ import React from 'react'; import { connect } from 'react-redux'; + 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...'; @@ -23,9 +25,13 @@ class Status extends React.Component { showWarningIcon = true; } else if (topology) { const stats = topology.get('stats'); - text = `${stats.get('node_count') - filteredNodeCount} nodes`; - if (stats.get('filtered_nodes')) { - text = `${text} (${stats.get('filtered_nodes') + filteredNodeCount} filtered)`; + if (showingCurrentState) { + text = `${stats.get('node_count') - filteredNodeCount} nodes`; + if (stats.get('filtered_nodes')) { + text = `${text} (${stats.get('filtered_nodes') + filteredNodeCount} filtered)`; + } + } else { + text = ''; } classNames += ' status-stats'; showWarningIcon = false; @@ -44,9 +50,10 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, + showingCurrentState: !state.get('websocketQueryTimestamp'), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), - websocketClosed: state.get('websocketClosed') + websocketClosed: state.get('websocketClosed'), }; } diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 30031654f2..afe46b8a61 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -118,6 +118,10 @@ class TimelineControl extends React.PureComponent { this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); } + componentWillUnmount() { + this.updateTimestamp(moment()); + } + updateTimestamp(timestamp) { this.props.websocketQueryTimestamp(timestamp); } diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index 6f6b101877..83234b6181 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -1,6 +1,8 @@ /* 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 TOPOLOGY_LOADER_DELAY = 100; export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10; export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200; diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 82cf1f65c8..f829bcbe27 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -133,15 +133,22 @@ export function getCurrentTopologyOptions(state) { return state.getIn(['currentTopology', 'options']); } -export function isTopologyEmpty(state) { +export function isTopologyNodeCountZero(state) { + return state.getIn(['currentTopology', 'stats', 'node_count'], 0) === 0; +} + +export function isNodesDisplayEmpty(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. - const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0); - const nodesEmpty = nodeCount === 0 && state.get('nodes').size === 0; - return resourceViewEmpty || nodesEmpty; + 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/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 54d0274640..77a97a91c0 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,6 +1,5 @@ import debug from 'debug'; import reqwest from 'reqwest'; -import moment from 'moment'; import { defaults } from 'lodash'; import { fromJS, Map as makeMap, List } from 'immutable'; @@ -13,7 +12,7 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr import { getCurrentTopologyUrl } from '../utils/topology-utils'; import { layersTopologyIdsSelector } from '../selectors/resource-view/layout'; import { activeTopologyOptionsSelector } from '../selectors/topology'; -import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer'; +import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer'; const log = debug('scope:web-api-utils'); @@ -96,12 +95,6 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l } function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) { - // If the timestamp stands for a time less than one second ago, - // assume we are actually interested in the current time. - if (moment().diff(moment(queryTimestamp)) < 1000) { - queryTimestamp = null; - } - const query = buildUrlQuery(fromJS({ t: updateFrequency, timestamp: queryTimestamp, @@ -227,7 +220,7 @@ export function getTopologies(options, dispatch, initialPoll) { dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { getTopologies(options, dispatch); - }, TOPOLOGY_INTERVAL); + }, TOPOLOGY_REFRESH_INTERVAL); } }, error: (req) => { @@ -237,7 +230,7 @@ export function getTopologies(options, dispatch, initialPoll) { if (continuePolling) { topologyTimer = setTimeout(() => { getTopologies(options, dispatch); - }, TOPOLOGY_INTERVAL); + }, TOPOLOGY_REFRESH_INTERVAL); } } }); @@ -304,7 +297,7 @@ export function getApiDetails(dispatch) { if (continuePolling) { apiDetailsTimer = setTimeout(() => { getApiDetails(dispatch); - }, API_INTERVAL); + }, API_REFRESH_INTERVAL); } }, error: (req) => { @@ -313,7 +306,7 @@ export function getApiDetails(dispatch) { if (continuePolling) { apiDetailsTimer = setTimeout(() => { getApiDetails(dispatch); - }, API_INTERVAL / 2); + }, API_REFRESH_INTERVAL / 2); } } }); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 4f7bd474db..9296fb7529 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -348,6 +348,8 @@ opacity: 0.25; font-size: 320px; } + + li { padding-top: 5px; } } &-loading &-error-icon-container { From d2fa7f28ae00751ea5a96a7f9ab74d70e13851d1 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 6 Jun 2017 12:26:31 +0200 Subject: [PATCH 11/21] Addressed some of David's comments. --- client/app/scripts/actions/app-actions.js | 24 ++++--- client/app/scripts/charts/nodes-layout.js | 2 +- client/app/scripts/components/nodes.js | 10 +-- client/app/scripts/components/running-time.js | 34 --------- client/app/scripts/components/status.js | 2 +- .../scripts/components/timeline-control.js | 24 ++++--- .../components/topology-timestamp-info.js | 71 +++++++++++++++++++ .../scripts/reducers/__tests__/root-test.js | 9 +++ client/app/scripts/reducers/root.js | 10 ++- client/app/scripts/utils/web-api-utils.js | 4 +- client/app/styles/_base.scss | 36 +++++----- client/app/styles/_variables.scss | 2 - 12 files changed, 142 insertions(+), 86 deletions(-) delete mode 100644 client/app/scripts/components/running-time.js create mode 100644 client/app/scripts/components/topology-timestamp-info.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 2568bf9e26..e6867cc4d7 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -14,7 +14,7 @@ import { doControlRequest, getAllNodes, getResourceViewNodesSnapshot, - updateNodesDeltaChannel, + updateWebsocketChannel, getNodeDetails, getTopologies, deletePipe, @@ -214,7 +214,7 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { resetUpdateBuffer(); const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - updateNodesDeltaChannel(state, dispatch); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -404,7 +404,7 @@ function updateTopology(dispatch, getState) { // 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. - updateNodesDeltaChannel(state, dispatch); + updateWebsocketChannel(state, dispatch); } export function clickShowTopologyForNode(topologyId, nodeId) { @@ -434,21 +434,23 @@ export function startMovingInTime() { }; } -export function websocketQueryTimestamp(timestamp) { +export function websocketQueryTimestamp(queryTimestamp) { + const requestTimestamp = moment(); // If the timestamp stands for a time less than one second ago, // assume we are actually interested in the current time. - if (timestamp && moment().diff(timestamp) >= 1000) { - timestamp = timestamp.toISOString(); + if (requestTimestamp.diff(queryTimestamp) >= 1000) { + queryTimestamp = queryTimestamp.toISOString(); } else { - timestamp = null; + queryTimestamp = null; } return (dispatch, getState) => { dispatch({ type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - timestamp, + requestTimestamp, + queryTimestamp, }); - updateNodesDeltaChannel(getState(), dispatch); + updateWebsocketChannel(getState(), dispatch); // update all request workers with new options resetUpdateBuffer(); }; @@ -641,7 +643,7 @@ export function receiveTopologies(topologies) { topologies }); const state = getState(); - updateNodesDeltaChannel(state, dispatch); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), @@ -758,7 +760,7 @@ export function route(urlState) { // update all request workers with new options const state = getState(); getTopologies(activeTopologyOptionsSelector(state), dispatch); - updateNodesDeltaChannel(state, dispatch); + updateWebsocketChannel(state, dispatch); getNodeDetails( state.get('topologyUrlsById'), state.get('currentTopologyId'), diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 6cd8bf3e53..f061f83156 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -422,7 +422,7 @@ export function doLayout(immNodes, immEdges, opts) { const cacheId = buildTopologyCacheId(options.topologyId, options.topologyOptions); // one engine and node and edge caches per topology, to keep renderings similar - if (true || options.noCache || !topologyCaches[cacheId]) { + if (options.noCache || !topologyCaches[cacheId]) { topologyCaches[cacheId] = { nodeCache: makeMap(), edgeCache: makeMap(), diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index a8989e93d8..5ad8f079f5 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -22,13 +22,15 @@ import { import { TOPOLOGY_LOADER_DELAY } from '../constants/timer'; +// TODO: The information that we already have available on the frontend should enable +// us to determine which of these cases exactly is preventing us from seeing the nodes. const NODE_COUNT_ZERO_CAUSES = [ - "We haven't received any reports from probes recently. Are the probes properly connected?", - "Containers view only: you're not running Docker, or you don't have any containers", + 'We haven\'t received any reports from probes recently. Are the probes properly connected?', + 'Containers view only: you\'re not running Docker, or you don\'t have any containers', ]; - const NODES_DISPLAY_EMPTY_CAUSES = [ - "There are nodes, but they're currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.", + 'There are nodes, but they\'re currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.', + 'There are no nodes for this particular moment in time. Use the timeline feature at the bottom-right corner to explore different times.', ]; const renderCauses = causes => ( diff --git a/client/app/scripts/components/running-time.js b/client/app/scripts/components/running-time.js deleted file mode 100644 index 2d4affb524..0000000000 --- a/client/app/scripts/components/running-time.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import moment from 'moment'; - - -export default class RunningTime extends React.PureComponent { - constructor(props, context) { - super(props, context); - - this.state = this.getFreshState(); - } - - componentDidMount() { - this.timer = setInterval(() => { - if (!this.props.paused) { - this.setState(this.getFreshState()); - } - }, 500); - } - - componentWillUnmount() { - clearInterval(this.timer); - } - - getFreshState() { - const timestamp = moment().utc().subtract(this.props.offsetMilliseconds); - return { humanizedTimestamp: timestamp.format('MMMM Do YYYY, h:mm:ss a') }; - } - - render() { - return ( - {this.state.humanizedTimestamp} - ); - } -} diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index 9d83bb5de8..5d99297167 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -50,7 +50,7 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, - showingCurrentState: !state.get('websocketQueryTimestamp'), + showingCurrentState: !state.get('websocketQueryPastAt'), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), websocketClosed: state.get('websocketClosed'), diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index afe46b8a61..ed478e2afa 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import RunningTime from './running-time'; +import TopologyTimestampInfo from './topology-timestamp-info'; import { getUpdateBufferSize } from '../utils/update-buffer-utils'; import { clickPauseUpdate, @@ -100,6 +100,13 @@ const sliderRanges = { }, }; +//
+// {this.renderRangeOption(sliderRanges.yesterday)} +// {this.renderRangeOption(sliderRanges.previousWeek)} +// {this.renderRangeOption(sliderRanges.previousMonth)} +// {this.renderRangeOption(sliderRanges.previousYear)} +//
+ class TimelineControl extends React.PureComponent { constructor(props, context) { super(props, context); @@ -199,7 +206,7 @@ class TimelineControl extends React.PureComponent { return (
{showTimelinePanel &&
- Move the slider to explore + Explore
{this.renderRangeOption(sliderRanges.last15Minutes)} @@ -219,13 +226,8 @@ class TimelineControl extends React.PureComponent { {this.renderRangeOption(sliderRanges.thisMonthSoFar)} {this.renderRangeOption(sliderRanges.thisYearSoFar)}
-
- {this.renderRangeOption(sliderRanges.yesterday)} - {this.renderRangeOption(sliderRanges.previousWeek)} - {this.renderRangeOption(sliderRanges.previousMonth)} - {this.renderRangeOption(sliderRanges.previousYear)} -
+ Move the slider to travel in time
}
- + - {pauseLabel !== '' && {pauseLabel}} + {pauseLabel !== '' && {pauseLabel}} {!showingCurrent && } diff --git a/client/app/scripts/components/topology-timestamp-info.js b/client/app/scripts/components/topology-timestamp-info.js new file mode 100644 index 0000000000..550b9a4561 --- /dev/null +++ b/client/app/scripts/components/topology-timestamp-info.js @@ -0,0 +1,71 @@ +import React from 'react'; +import moment from 'moment'; +import { connect } from 'react-redux'; + + +const TIMESTAMP_TICK_INTERVAL = 500; + +class TopologyTimestampInfo extends React.PureComponent { + constructor(props, context) { + super(props, context); + + this.state = this.getFreshState(); + } + + componentDidMount() { + this.timer = setInterval(() => { + if (!this.props.paused) { + this.setState(this.getFreshState()); + } + }, TIMESTAMP_TICK_INTERVAL); + } + + componentWillUnmount() { + clearInterval(this.timer); + } + + getFreshState() { + const { updatePausedAt, websocketQueryPastAt, websocketQueryPastRequestMadeAt } = this.props; + + let timestamp = updatePausedAt; + let showingCurrentState = false; + + if (!updatePausedAt) { + timestamp = moment().utc(); + showingCurrentState = true; + + if (websocketQueryPastAt) { + const offset = moment(websocketQueryPastRequestMadeAt).diff(moment(websocketQueryPastAt)); + timestamp = timestamp.subtract(offset); + showingCurrentState = false; + } + } + return { timestamp, showingCurrentState }; + } + + renderTimestamp() { + return ( + + ); + } + + render() { + const { showingCurrentState } = this.state; + + return ( + + {showingCurrentState ? 'now' : this.renderTimestamp()} + + ); + } +} + +function mapStateToProps(state) { + return { + updatePausedAt: state.get('updatePausedAt'), + websocketQueryPastAt: state.get('websocketQueryPastAt'), + websocketQueryPastRequestMadeAt: state.get('websocketQueryPastRequestMadeAt'), + }; +} + +export default connect(mapStateToProps)(TopologyTimestampInfo); diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index c408e5ec47..40062001b0 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -4,6 +4,7 @@ import expect from 'expect'; import { TABLE_VIEW_MODE } from '../../constants/naming'; // Root reducer test suite using Jasmine matchers import { constructEdgeId } from '../../utils/layouter-utils'; +// import { isResourceViewModeSelector } from '../../selectors/topology'; describe('RootReducer', () => { const ActionTypes = require('../../constants/action-types').default; @@ -15,6 +16,7 @@ describe('RootReducer', () => { const activeTopologyOptionsSelector = topologySelectors.activeTopologyOptionsSelector; const getAdjacentNodes = topologyUtils.getAdjacentNodes; const isTopologyEmpty = topologyUtils.isTopologyEmpty; + const isNodesDisplayEmpty = topologyUtils.isNodesDisplayEmpty; const getUrlState = require('../../utils/router-utils').getUrlState; // fixtures @@ -536,12 +538,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 869bf90623..10526ff75e 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -1,5 +1,6 @@ /* 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, @@ -90,7 +91,8 @@ export const initialState = makeMap({ viewport: makeMap(), websocketClosed: false, websocketMovingInTime: false, - websocketQueryTimestamp: null, + websocketQueryPastAt: null, + websocketQueryPastRequestMadeAt: null, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -293,7 +295,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_PAUSE_UPDATE: { - return state.set('updatePausedAt', new Date()); + return state.set('updatePausedAt', moment().utc()); } case ActionTypes.CLICK_RELATIVE: { @@ -353,7 +355,9 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { - return state.set('websocketQueryTimestamp', action.timestamp); + const websocketPastRequestMadeAt = action.queryTimestamp ? action.requestTimestamp : null; + state = state.set('websocketQueryPastRequestMadeAt', websocketPastRequestMadeAt); + return state.set('websocketQueryPastAt', action.queryTimestamp); } case ActionTypes.CLOSE_WEBSOCKET: { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 77a97a91c0..f07b752e35 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -236,10 +236,10 @@ export function getTopologies(options, dispatch, initialPoll) { }); } -export function updateNodesDeltaChannel(state, dispatch) { +export function updateWebsocketChannel(state, dispatch) { const topologyUrl = getCurrentTopologyUrl(state); const topologyOptions = activeTopologyOptionsSelector(state); - const queryTimestamp = state.get('websocketQueryTimestamp'); + const queryTimestamp = state.get('websocketQueryPastAt'); const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp); // Only recreate websocket if url changed or if forced (weave cloud instance reload); const isNewUrl = websocketUrl !== currentUrl; diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 9296fb7529..23a810375b 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -61,6 +61,7 @@ justify-content: center; padding: 5px; position: absolute; + bottom: 11px; a { @extend .btn-opacity; @@ -68,7 +69,7 @@ border-radius: 4px; color: $text-secondary-color; cursor: pointer; - padding: 4px 3px; + padding: 1px 3px; .fa { font-size: 150%; @@ -177,7 +178,6 @@ .footer { @extend .overlay-wrapper; - bottom: 11px; right: 43px; &-status { @@ -194,6 +194,10 @@ text-transform: uppercase; } + &-tools { + display: flex; + } + &-icon { margin-left: 0.5em; } @@ -213,24 +217,22 @@ .timeline-control { @extend .overlay-wrapper; display: block; - left: calc(50vw - #{$timeline-control-width} / 2); - width: $timeline-control-width; - bottom: 0; + right: 450px; .time-status { display: flex; align-items: center; - padding: 0 9px; + justify-content: flex-end; - .running-time { font-size: 115%; } - .button { margin-left: 0.5em; } - - .jump-to-now { - @extend .blinkable; - margin-left: auto; + .topology-timestamp-info, .pause-text { + font-size: 115%; + margin-right: 5px; } - &:not(.showing-current) .running-time { + .button { margin-left: 0.5em; } + .jump-to-now { @extend .blinkable; } + + &:not(.showing-current) .topology-timestamp-info { font-weight: bold; } } @@ -259,7 +261,7 @@ } .rc-slider { - margin: 0 10px 3px; + margin: 0 10px 8px; width: auto; } } @@ -1480,9 +1482,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; diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss index e60fe51316..801f8738f4 100644 --- a/client/app/styles/_variables.scss +++ b/client/app/styles/_variables.scss @@ -53,8 +53,6 @@ $link-opacity-default: 0.8; $search-border-color: transparent; $search-border-width: 1px; -$timeline-control-width: 405px; - /* specific elements */ $body-background-color: linear-gradient(30deg, $background-color 0%, $background-lighter-color 100%); $label-background-color: fade-out($background-average-color, .3); From d88993d09b2689a48385edeb583cf67f81622c94 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 6 Jun 2017 14:05:32 +0200 Subject: [PATCH 12/21] Moved nodesDeltaBuffer to a global state to fix the paused status rendering bug. --- client/app/scripts/actions/app-actions.js | 79 ++++++++++++---- client/app/scripts/components/pause-button.js | 49 ++++++++++ .../scripts/components/timeline-control.js | 89 +++++-------------- client/app/scripts/constants/action-types.js | 4 + .../scripts/reducers/__tests__/root-test.js | 4 +- client/app/scripts/reducers/root.js | 25 ++++++ .../app/scripts/utils/update-buffer-utils.js | 70 +++------------ 7 files changed, 177 insertions(+), 143 deletions(-) create mode 100644 client/app/scripts/components/pause-button.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e6867cc4d7..3b1840f9ff 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -6,9 +6,8 @@ import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; import { - bufferDeltaUpdate, - resumeUpdate, - resetUpdateBuffer, + isNodesDeltaPaused, + getUpdateBufferSize, } from '../utils/update-buffer-utils'; import { doControlRequest, @@ -43,6 +42,9 @@ import { 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 +77,30 @@ export function sortOrderChanged(sortedBy, sortedDesc) { }; } +function resetNodesDeltaBuffer() { + clearTimeout(nodesDeltaBufferUpdateTimer); + return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; +} + +function bufferDeltaUpdate(delta) { + return (dispatch, getState) => { + if (delta.add === null && delta.update === null && delta.remove === null) { + log('Discarding empty nodes delta'); + return; + } + + const bufferLength = 100; + if (getUpdateBufferSize(getState()) >= bufferLength) { + dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); + } + + dispatch({ + type: ActionTypes.ADD_TO_NODES_DELTA_BUFFER, + delta, + }); + log('Buffering node delta, new size', getUpdateBufferSize(getState())); + }; +} // // Networks @@ -211,7 +237,7 @@ 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); updateWebsocketChannel(state, dispatch); @@ -383,15 +409,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. @@ -400,7 +417,7 @@ 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. @@ -452,7 +469,7 @@ export function websocketQueryTimestamp(queryTimestamp) { }); updateWebsocketChannel(getState(), dispatch); // update all request workers with new options - resetUpdateBuffer(); + dispatch(resetNodesDeltaBuffer()); }; } @@ -616,7 +633,7 @@ export function receiveNodesDelta(delta) { if (hasChanges || movingInTime) { if (state.get('updatePausedAt') !== null) { - bufferDeltaUpdate(delta); + dispatch(bufferDeltaUpdate(delta)); } else { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, @@ -627,6 +644,36 @@ export function receiveNodesDelta(delta) { }; } +function maybeUpdateFromNodesDeltaBuffer() { + return (dispatch, getState) => { + const state = getState(); + if (isNodesDeltaPaused(state)) { + dispatch(resetNodesDeltaBuffer()); + } else { + if (getUpdateBufferSize(state) > 0) { + const delta = state.get('nodesDeltaBuffer').first(); + dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); + dispatch(receiveNodesDelta(delta)); + } + if (getUpdateBufferSize(state) > 0) { + const feedInterval = 1000; + nodesDeltaBufferUpdateTimer = setTimeout( + () => dispatch(maybeUpdateFromNodesDeltaBuffer()), + feedInterval); + } + } + }; +} + +export function clickResumeUpdate() { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_RESUME_UPDATE + }); + dispatch(maybeUpdateFromNodesDeltaBuffer(getState)); + }; +} + export function receiveNodesForTopology(nodes, topologyId) { return { type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY, diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js new file mode 100644 index 0000000000..af547ee894 --- /dev/null +++ b/client/app/scripts/components/pause-button.js @@ -0,0 +1,49 @@ +import React from 'react'; +import moment from 'moment'; +import { connect } from 'react-redux'; + +import { getUpdateBufferSize } from '../utils/update-buffer-utils'; +import { clickPauseUpdate, clickResumeUpdate } from '../actions/app-actions'; + + +class PauseButton extends React.Component { + render() { + const isPaused = this.props.updatePausedAt !== null; + const updateCount = this.props.updateCount; + const hasUpdates = updateCount > 0; + const title = isPaused ? + `Paused ${moment(this.props.updatePausedAt).fromNow()}` : + 'Pause updates (freezes the nodes in their current layout)'; + const action = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; + let label = ''; + if (hasUpdates && isPaused) { + label = `Paused +${updateCount}`; + } else if (hasUpdates && !isPaused) { + label = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPaused) { + label = 'Paused'; + } + + return ( + + {label !== '' && {label}} + + + ); + } +} + +function mapStateToProps(state) { + return { + updateCount: getUpdateBufferSize(state), + updatePausedAt: state.get('updatePausedAt'), + }; +} + +export default connect( + mapStateToProps, + { + clickPauseUpdate, + clickResumeUpdate, + } +)(PauseButton); diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index ed478e2afa..d07c8441d9 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -5,15 +5,9 @@ import classNames from 'classnames'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import PauseButton from './pause-button'; import TopologyTimestampInfo from './topology-timestamp-info'; -import { getUpdateBufferSize } from '../utils/update-buffer-utils'; -import { - clickPauseUpdate, - clickResumeUpdate, - websocketQueryTimestamp, - startMovingInTime, -} from '../actions/app-actions'; - +import { websocketQueryTimestamp, startMovingInTime } from '../actions/app-actions'; import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; @@ -78,35 +72,28 @@ const sliderRanges = { getStart: () => moment().utc().startOf('year'), getEnd: () => moment().utc(), }, - yesterday: { - label: 'Yesterday', - getStart: () => moment().utc().subtract(1, 'day').startOf('day'), - getEnd: () => moment().utc().subtract(1, 'day').endOf('day'), - }, - previousWeek: { - label: 'Previous week', - getStart: () => moment().utc().subtract(1, 'week').startOf('week'), - getEnd: () => moment().utc().subtract(1, 'week').endOf('week'), - }, - previousMonth: { - label: 'Previous month', - getStart: () => moment().utc().subtract(1, 'month').startOf('month'), - getEnd: () => moment().utc().subtract(1, 'month').endOf('month'), - }, - previousYear: { - label: 'Previous year', - getStart: () => moment().utc().subtract(1, 'year').startOf('year'), - getEnd: () => moment().utc().subtract(1, 'year').endOf('year'), - }, + // yesterday: { + // label: 'Yesterday', + // getStart: () => moment().utc().subtract(1, 'day').startOf('day'), + // getEnd: () => moment().utc().subtract(1, 'day').endOf('day'), + // }, + // previousWeek: { + // label: 'Previous week', + // getStart: () => moment().utc().subtract(1, 'week').startOf('week'), + // getEnd: () => moment().utc().subtract(1, 'week').endOf('week'), + // }, + // previousMonth: { + // label: 'Previous month', + // getStart: () => moment().utc().subtract(1, 'month').startOf('month'), + // getEnd: () => moment().utc().subtract(1, 'month').endOf('month'), + // }, + // previousYear: { + // label: 'Previous year', + // getStart: () => moment().utc().subtract(1, 'year').startOf('year'), + // getEnd: () => moment().utc().subtract(1, 'year').endOf('year'), + // }, }; -//
-// {this.renderRangeOption(sliderRanges.yesterday)} -// {this.renderRangeOption(sliderRanges.previousWeek)} -// {this.renderRangeOption(sliderRanges.previousMonth)} -// {this.renderRangeOption(sliderRanges.previousYear)} -//
- class TimelineControl extends React.PureComponent { constructor(props, context) { super(props, context); @@ -186,23 +173,6 @@ class TimelineControl extends React.PureComponent { const timeStatusClassName = classNames('time-status', { 'showing-current': showingCurrent }); const toggleButtonClassName = classNames('button toggle', { selected: showTimelinePanel }); - // pause button - const isPaused = this.props.updatePausedAt !== null; - const updateCount = getUpdateBufferSize(); - const hasUpdates = updateCount > 0; - const pauseTitle = isPaused ? - `Paused ${moment(this.props.updatePausedAt).fromNow()}` : - 'Pause updates (freezes the nodes in their current layout)'; - const pauseAction = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; - let pauseLabel = ''; - if (hasUpdates && isPaused) { - pauseLabel = `Paused +${updateCount}`; - } else if (hasUpdates && !isPaused) { - pauseLabel = `Resuming +${updateCount}`; - } else if (!hasUpdates && isPaused) { - pauseLabel = 'Paused'; - } - return (
{showTimelinePanel &&
@@ -239,10 +209,7 @@ class TimelineControl extends React.PureComponent { - - {pauseLabel !== '' && {pauseLabel}} - - + {!showingCurrent && { const ActionTypes = require('../../constants/action-types').default; const reducer = require('../root').default; @@ -16,7 +15,6 @@ describe('RootReducer', () => { const activeTopologyOptionsSelector = topologySelectors.activeTopologyOptionsSelector; const getAdjacentNodes = topologyUtils.getAdjacentNodes; const isTopologyEmpty = topologyUtils.isTopologyEmpty; - const isNodesDisplayEmpty = topologyUtils.isNodesDisplayEmpty; const getUrlState = require('../../utils/router-utils').getUrlState; // fixtures diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 10526ff75e..d54fd0cd4c 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -22,6 +22,7 @@ import { isResourceViewModeSelector, } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; +import { consolidatedBeginningOfNodesDeltaBuffer } from '../utils/update-buffer-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -63,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 @@ -364,6 +366,29 @@ export function rootReducer(state = initialState, action) { 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 deltaUnion = consolidatedBeginningOfNodesDeltaBuffer(state); + state = state.setIn(['nodesDeltaBuffer', 0], deltaUnion); + state = state.deleteIn(['nodesDeltaBuffer', 1]); + return state; + } + + case ActionTypes.POP_NODES_DELTA_BUFFER: { + return state.update('nodesDeltaBuffer', buffer => buffer.shift()); + } + + case ActionTypes.ADD_TO_NODES_DELTA_BUFFER: { + return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta)); + } + // // networks // diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js index cd65da5516..665a77480e 100644 --- a/client/app/scripts/utils/update-buffer-utils.js +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -1,47 +1,17 @@ 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); - } - } +export function isNodesDeltaPaused(state) { + return state.get('updatePausedAt') !== null; } // consolidate first buffer entry with second -function consolidateBuffer() { - const first = deltaBuffer.first(); - deltaBuffer = deltaBuffer.shift(); - const second = deltaBuffer.first(); +export function consolidatedBeginningOfNodesDeltaBuffer(state) { + const first = state.getIn(['nodesDeltaBuffer', 0]); + const second = state.getIn(['nodesDeltaBuffer', 1]); let toAdd = union(first.add, second.add); let toUpdate = union(first.update, second.update); let toRemove = union(first.remove, second.remove); @@ -78,37 +48,19 @@ function consolidateBuffer() { } }); - // check if an removed node in first was added in second -> update + // 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, { + + return { 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); +export function getUpdateBufferSize(state) { + return state.get('nodesDeltaBuffer').size; } From ed03fd8e2e8380f2045154a25120b71750de8e33 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 6 Jun 2017 17:33:13 +0200 Subject: [PATCH 13/21] Small styling changes --- client/app/scripts/actions/app-actions.js | 2 - client/app/scripts/components/nodes.js | 6 +- client/app/scripts/components/pause-button.js | 4 +- .../scripts/components/timeline-control.js | 85 ++++++++++++------- ...p-info.js => topology-timestamp-button.js} | 27 +++--- client/app/scripts/reducers/root.js | 3 - client/app/styles/_base.scss | 20 +++-- 7 files changed, 91 insertions(+), 56 deletions(-) rename client/app/scripts/components/{topology-timestamp-info.js => topology-timestamp-button.js} (62%) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 3b1840f9ff..5855fcdd70 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -464,11 +464,9 @@ export function websocketQueryTimestamp(queryTimestamp) { return (dispatch, getState) => { dispatch({ type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - requestTimestamp, queryTimestamp, }); updateWebsocketChannel(getState(), dispatch); - // update all request workers with new options dispatch(resetNodesDeltaBuffer()); }; } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 5ad8f079f5..a8fb1dd751 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -56,9 +56,9 @@ class Nodes extends React.Component { render() { const { topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode, - isTableViewMode, isResourceViewMode, blurred } = this.props; + isTableViewMode, isResourceViewMode, movingInTime } = this.props; - const className = classNames('nodes-wrapper', { blurred }); + const className = classNames('nodes-wrapper', { blurred: movingInTime }); // TODO: Rename view mode components. return ( @@ -89,7 +89,7 @@ function mapStateToProps(state) { topologyNodeCountZero: isTopologyNodeCountZero(state), nodesDisplayEmpty: isNodesDisplayEmpty(state), topologyEmpty: isTopologyEmpty(state), - blurred: state.get('websocketMovingInTime'), + movingInTime: state.get('websocketMovingInTime'), currentTopology: state.get('currentTopology'), nodesLoaded: state.get('nodesLoaded') || state.get('websocketMovingInTime'), topologies: state.get('topologies'), diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index af547ee894..e363c5db57 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -1,5 +1,6 @@ import React from 'react'; import moment from 'moment'; +import classNames from 'classnames'; import { connect } from 'react-redux'; import { getUpdateBufferSize } from '../utils/update-buffer-utils'; @@ -15,6 +16,7 @@ class PauseButton extends React.Component { `Paused ${moment(this.props.updatePausedAt).fromNow()}` : 'Pause updates (freezes the nodes in their current layout)'; const action = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; + const className = classNames('button pause-button', { active: isPaused }); let label = ''; if (hasUpdates && isPaused) { label = `Paused +${updateCount}`; @@ -25,7 +27,7 @@ class PauseButton extends React.Component { } return ( - + {label !== '' && {label}} diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index d07c8441d9..9d3f25b306 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -6,8 +6,13 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; import PauseButton from './pause-button'; -import TopologyTimestampInfo from './topology-timestamp-info'; -import { websocketQueryTimestamp, startMovingInTime } from '../actions/app-actions'; +import TopologyTimestampButton from './topology-timestamp-button'; +import { + websocketQueryTimestamp, + clickResumeUpdate, + startMovingInTime, +} from '../actions/app-actions'; + import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; @@ -94,7 +99,7 @@ const sliderRanges = { // }, }; -class TimelineControl extends React.PureComponent { +class TimelineControl extends React.Component { constructor(props, context) { super(props, context); @@ -118,6 +123,7 @@ class TimelineControl extends React.PureComponent { updateTimestamp(timestamp) { this.props.websocketQueryTimestamp(timestamp); + this.props.clickResumeUpdate(); } toggleTimelinePanel() { @@ -147,6 +153,12 @@ class TimelineControl extends React.PureComponent { this.updateTimestamp(moment()); } + getTotalOffset() { + const { rangeOptionSelected, offsetMilliseconds } = this.state; + const rangeBehindMilliseconds = moment().diff(rangeOptionSelected.getEnd()); + return offsetMilliseconds + rangeBehindMilliseconds; + } + renderRangeOption(option) { const handleClick = () => { this.setState({ rangeOptionSelected: option }); }; const selected = (this.state.rangeOptionSelected.label === option.label); @@ -159,24 +171,37 @@ class TimelineControl extends React.PureComponent { ); } - getTotalOffset() { - const { rangeOptionSelected, offsetMilliseconds } = this.state; - const rangeBehindMilliseconds = moment().diff(rangeOptionSelected.getEnd()); - return offsetMilliseconds + rangeBehindMilliseconds; + renderJumpToNowButton() { + return ( + + + + ); + } + + renderTimelineSlider() { + const { offsetMilliseconds } = this.state; + const rangeMilliseconds = this.getRangeMilliseconds(); + + return ( + + ); } render() { + const { movingInTime } = this.props; const { showTimelinePanel, offsetMilliseconds } = this.state; - const rangeMilliseconds = this.getRangeMilliseconds(); const showingCurrent = (this.getTotalOffset() === 0); - const timeStatusClassName = classNames('time-status', { 'showing-current': showingCurrent }); - const toggleButtonClassName = classNames('button toggle', { selected: showTimelinePanel }); return (
{showTimelinePanel &&
- Explore + Explore
{this.renderRangeOption(sliderRanges.last15Minutes)} @@ -197,35 +222,37 @@ class TimelineControl extends React.PureComponent { {this.renderRangeOption(sliderRanges.thisYearSoFar)}
- Move the slider to travel in time - + Move the slider to travel back in time + {this.renderTimelineSlider()}
} -
- - - - +
+ {movingInTime &&
+ +
} + + {!showingCurrent && this.renderJumpToNowButton()} - {!showingCurrent && - - }
); } } +function mapStateToProps(state) { + return { + movingInTime: state.get('websocketMovingInTime'), + }; +} + export default connect( - null, + mapStateToProps, { websocketQueryTimestamp, + clickResumeUpdate, startMovingInTime, } )(TimelineControl); diff --git a/client/app/scripts/components/topology-timestamp-info.js b/client/app/scripts/components/topology-timestamp-button.js similarity index 62% rename from client/app/scripts/components/topology-timestamp-info.js rename to client/app/scripts/components/topology-timestamp-button.js index 550b9a4561..727012d72c 100644 --- a/client/app/scripts/components/topology-timestamp-info.js +++ b/client/app/scripts/components/topology-timestamp-button.js @@ -1,11 +1,12 @@ import React from 'react'; import moment from 'moment'; +import classNames from 'classnames'; import { connect } from 'react-redux'; const TIMESTAMP_TICK_INTERVAL = 500; -class TopologyTimestampInfo extends React.PureComponent { +class TopologyTimestampButton extends React.PureComponent { constructor(props, context) { super(props, context); @@ -25,7 +26,7 @@ class TopologyTimestampInfo extends React.PureComponent { } getFreshState() { - const { updatePausedAt, websocketQueryPastAt, websocketQueryPastRequestMadeAt } = this.props; + const { updatePausedAt, offset } = this.props; let timestamp = updatePausedAt; let showingCurrentState = false; @@ -34,8 +35,7 @@ class TopologyTimestampInfo extends React.PureComponent { timestamp = moment().utc(); showingCurrentState = true; - if (websocketQueryPastAt) { - const offset = moment(websocketQueryPastRequestMadeAt).diff(moment(websocketQueryPastAt)); + if (offset >= 1000) { timestamp = timestamp.subtract(offset); showingCurrentState = false; } @@ -45,17 +45,24 @@ class TopologyTimestampInfo extends React.PureComponent { renderTimestamp() { return ( - + ); } render() { + const { selected, onClick } = this.props; const { showingCurrentState } = this.state; + const className = classNames('button topology-timestamp-button', { + selected, current: showingCurrentState, + }); return ( - - {showingCurrentState ? 'now' : this.renderTimestamp()} - + + + {showingCurrentState ? 'now' : this.renderTimestamp()} + + + ); } } @@ -63,9 +70,7 @@ class TopologyTimestampInfo extends React.PureComponent { function mapStateToProps(state) { return { updatePausedAt: state.get('updatePausedAt'), - websocketQueryPastAt: state.get('websocketQueryPastAt'), - websocketQueryPastRequestMadeAt: state.get('websocketQueryPastRequestMadeAt'), }; } -export default connect(mapStateToProps)(TopologyTimestampInfo); +export default connect(mapStateToProps)(TopologyTimestampButton); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index d54fd0cd4c..190e53f158 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -94,7 +94,6 @@ export const initialState = makeMap({ websocketClosed: false, websocketMovingInTime: false, websocketQueryPastAt: null, - websocketQueryPastRequestMadeAt: null, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -357,8 +356,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { - const websocketPastRequestMadeAt = action.queryTimestamp ? action.requestTimestamp : null; - state = state.set('websocketQueryPastRequestMadeAt', websocketPastRequestMadeAt); return state.set('websocketQueryPastAt', action.queryTimestamp); } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 23a810375b..26c5a542fa 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -81,7 +81,7 @@ border: 1px solid $text-tertiary-color; } - .active { + &.active { @extend .blinkable; border: 1px solid $text-tertiary-color; } @@ -217,35 +217,41 @@ .timeline-control { @extend .overlay-wrapper; display: block; - right: 450px; + right: 500px; .time-status { display: flex; align-items: center; justify-content: flex-end; + .timeline-jump-loader { + font-size: 1rem; + } + .topology-timestamp-info, .pause-text { font-size: 115%; margin-right: 5px; } .button { margin-left: 0.5em; } - .jump-to-now { @extend .blinkable; } - &:not(.showing-current) .topology-timestamp-info { + .topology-timestamp-button:not(.current) { + @extend .blinkable; font-weight: bold; } } .timeline-panel { - strong { + width: 355px; + + .caption, .slider-tip { display: inline-block; font-size: 0.8125rem; - font-weight: normal; padding: 5px 10px; - text-decoration: underline; } + .slider-tip { font-style: italic; } + .options { display: flex; padding: 2px 0 10px; From 9c0d1147d39ee6e39fe5992a8184a6e8bc53e819 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 6 Jun 2017 19:32:49 +0200 Subject: [PATCH 14/21] Changed the websocket global state variables a bit. --- client/app/scripts/actions/app-actions.js | 12 ++++-------- client/app/scripts/components/status.js | 2 +- client/app/scripts/components/timeline-control.js | 13 ++++++------- client/app/scripts/reducers/root.js | 9 ++++++--- client/app/scripts/utils/web-api-utils.js | 8 +++++++- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 5855fcdd70..a3c98cd650 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,5 +1,4 @@ import debug from 'debug'; -import moment from 'moment'; import { find } from 'lodash'; import ActionTypes from '../constants/action-types'; @@ -451,20 +450,17 @@ export function startMovingInTime() { }; } -export function websocketQueryTimestamp(queryTimestamp) { - const requestTimestamp = moment(); +export function websocketQueryTimestamp(timestampSinceNow) { // If the timestamp stands for a time less than one second ago, // assume we are actually interested in the current time. - if (requestTimestamp.diff(queryTimestamp) >= 1000) { - queryTimestamp = queryTimestamp.toISOString(); - } else { - queryTimestamp = null; + if (timestampSinceNow < 1000) { + timestampSinceNow = null; } return (dispatch, getState) => { dispatch({ type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - queryTimestamp, + timestampSinceNow, }); updateWebsocketChannel(getState(), dispatch); dispatch(resetNodesDeltaBuffer()); diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index 5d99297167..d9c98677a8 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -50,7 +50,7 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, - showingCurrentState: !state.get('websocketQueryPastAt'), + showingCurrentState: !state.get('websocketTimestampOffset'), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), websocketClosed: state.get('websocketClosed'), diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 9d3f25b306..0e1e7b3860 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -118,11 +118,11 @@ class TimelineControl extends React.Component { } componentWillUnmount() { - this.updateTimestamp(moment()); + this.updateTimestamp(null); } - updateTimestamp(timestamp) { - this.props.websocketQueryTimestamp(timestamp); + updateTimestamp(timestampSinceNow) { + this.props.websocketQueryTimestamp(timestampSinceNow); this.props.clickResumeUpdate(); } @@ -132,9 +132,8 @@ class TimelineControl extends React.Component { handleSliderChange(value) { const offsetMilliseconds = this.getRangeMilliseconds() - value; - const timestamp = moment().utc().subtract(offsetMilliseconds); this.props.startMovingInTime(); - this.debouncedUpdateTimestamp(timestamp); + this.debouncedUpdateTimestamp(offsetMilliseconds); this.setState({ offsetMilliseconds }); } @@ -150,12 +149,12 @@ class TimelineControl extends React.Component { rangeOptionSelected: sliderRanges.last1Hour, }); this.props.startMovingInTime(); - this.updateTimestamp(moment()); + this.updateTimestamp(null); } getTotalOffset() { const { rangeOptionSelected, offsetMilliseconds } = this.state; - const rangeBehindMilliseconds = moment().diff(rangeOptionSelected.getEnd()); + const rangeBehindMilliseconds = moment().utc().diff(rangeOptionSelected.getEnd()); return offsetMilliseconds + rangeBehindMilliseconds; } diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 190e53f158..1689ceda50 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -23,6 +23,7 @@ import { } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { consolidatedBeginningOfNodesDeltaBuffer } from '../utils/update-buffer-utils'; +import { getWebsocketQueryTimestamp } from '../utils/web-api-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -93,7 +94,7 @@ export const initialState = makeMap({ viewport: makeMap(), websocketClosed: false, websocketMovingInTime: false, - websocketQueryPastAt: null, + websocketQueryTimestampSinceNow: null, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -296,7 +297,9 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_PAUSE_UPDATE: { - return state.set('updatePausedAt', moment().utc()); + const pausedAt = state.get('websocketQueryTimestampSinceNow') ? + moment(getWebsocketQueryTimestamp(state)) : moment().utc(); + return state.set('updatePausedAt', pausedAt); } case ActionTypes.CLICK_RELATIVE: { @@ -356,7 +359,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { - return state.set('websocketQueryPastAt', action.queryTimestamp); + return state.set('websocketQueryTimestampSinceNow', action.timestampSinceNow); } case ActionTypes.CLOSE_WEBSOCKET: { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index f07b752e35..7eae675963 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,4 +1,5 @@ import debug from 'debug'; +import moment from 'moment'; import reqwest from 'reqwest'; import { defaults } from 'lodash'; import { fromJS, Map as makeMap, List } from 'immutable'; @@ -236,10 +237,15 @@ export function getTopologies(options, dispatch, initialPoll) { }); } +export function getWebsocketQueryTimestamp(state) { + const sinceNow = state.get('websocketQueryTimestampSinceNow'); + return sinceNow ? moment().utc().subtract(sinceNow).toISOString() : null; +} + export function updateWebsocketChannel(state, dispatch) { const topologyUrl = getCurrentTopologyUrl(state); const topologyOptions = activeTopologyOptionsSelector(state); - const queryTimestamp = state.get('websocketQueryPastAt'); + const queryTimestamp = getWebsocketQueryTimestamp(state); const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp); // Only recreate websocket if url changed or if forced (weave cloud instance reload); const isNewUrl = websocketUrl !== currentUrl; From 780faf46bb747b8c3d704ac636113eb5a0d4f5e7 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 7 Jun 2017 12:45:40 +0200 Subject: [PATCH 15/21] Polishing & refactoring. --- client/app/scripts/actions/app-actions.js | 30 +++---- client/app/scripts/components/pause-button.js | 18 ++-- .../scripts/components/timeline-control.js | 89 +++++++------------ .../components/topology-timestamp-button.js | 48 ++++------ client/app/scripts/constants/limits.js | 1 + client/app/scripts/constants/timer.js | 3 + client/app/scripts/reducers/root.js | 12 +-- client/app/scripts/selectors/timeline.js | 9 ++ ...e-buffer-utils.js => nodes-delta-utils.js} | 28 +++--- 9 files changed, 99 insertions(+), 139 deletions(-) create mode 100644 client/app/scripts/selectors/timeline.js rename client/app/scripts/utils/{update-buffer-utils.js => nodes-delta-utils.js} (68%) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index a3c98cd650..d829ebffc3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -4,10 +4,6 @@ import { find } from 'lodash'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; -import { - isNodesDeltaPaused, - getUpdateBufferSize, -} from '../utils/update-buffer-utils'; import { doControlRequest, getAllNodes, @@ -21,6 +17,7 @@ import { } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; +import { isPausedSelector } from '../selectors/timeline'; import { availableMetricTypesSelector, nextPinnedMetricTypeSelector, @@ -32,11 +29,14 @@ 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'); @@ -88,8 +88,7 @@ function bufferDeltaUpdate(delta) { return; } - const bufferLength = 100; - if (getUpdateBufferSize(getState()) >= bufferLength) { + if (getState().get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); } @@ -97,7 +96,7 @@ function bufferDeltaUpdate(delta) { type: ActionTypes.ADD_TO_NODES_DELTA_BUFFER, delta, }); - log('Buffering node delta, new size', getUpdateBufferSize(getState())); + log('Buffering node delta, new size', getState().get('nodesDeltaBuffer').size); }; } @@ -626,7 +625,7 @@ export function receiveNodesDelta(delta) { const hasChanges = delta.add || delta.update || delta.remove; if (hasChanges || movingInTime) { - if (state.get('updatePausedAt') !== null) { + if (isPausedSelector(state)) { dispatch(bufferDeltaUpdate(delta)); } else { dispatch({ @@ -640,20 +639,19 @@ export function receiveNodesDelta(delta) { function maybeUpdateFromNodesDeltaBuffer() { return (dispatch, getState) => { - const state = getState(); - if (isNodesDeltaPaused(state)) { + if (isPausedSelector(getState())) { dispatch(resetNodesDeltaBuffer()); } else { - if (getUpdateBufferSize(state) > 0) { - const delta = state.get('nodesDeltaBuffer').first(); + if (!getState().get('nodesDeltaBuffer').isEmpty()) { + const delta = getState().get('nodesDeltaBuffer').first(); dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); dispatch(receiveNodesDelta(delta)); } - if (getUpdateBufferSize(state) > 0) { - const feedInterval = 1000; + if (!getState().get('nodesDeltaBuffer').isEmpty()) { nodesDeltaBufferUpdateTimer = setTimeout( () => dispatch(maybeUpdateFromNodesDeltaBuffer()), - feedInterval); + NODES_DELTA_BUFFER_FEED_INTERVAL, + ); } } }; diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index e363c5db57..328334d19e 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -3,20 +3,20 @@ import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import { getUpdateBufferSize } from '../utils/update-buffer-utils'; +import { isPausedSelector } from '../selectors/timeline'; import { clickPauseUpdate, clickResumeUpdate } from '../actions/app-actions'; class PauseButton extends React.Component { render() { - const isPaused = this.props.updatePausedAt !== null; - const updateCount = this.props.updateCount; - const hasUpdates = updateCount > 0; - const title = isPaused ? - `Paused ${moment(this.props.updatePausedAt).fromNow()}` : - 'Pause updates (freezes the nodes in their current layout)'; + const { isPaused, hasUpdates, updateCount, updatePausedAt } = this.props; const action = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; const className = classNames('button pause-button', { active: isPaused }); + + const title = isPaused ? + `Paused ${moment(updatePausedAt).fromNow()}` : + 'Pause updates (freezes the nodes in their current layout)'; + let label = ''; if (hasUpdates && isPaused) { label = `Paused +${updateCount}`; @@ -37,8 +37,10 @@ class PauseButton extends React.Component { function mapStateToProps(state) { return { - updateCount: getUpdateBufferSize(state), + hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), + updateCount: state.get('nodesDeltaBuffer').size, updatePausedAt: state.get('updatePausedAt'), + isPaused: isPausedSelector(state), }; } diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 0e1e7b3860..813c33559f 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -20,83 +20,51 @@ const sliderRanges = { last15Minutes: { label: 'Last 15 minutes', getStart: () => moment().utc().subtract(15, 'minutes'), - getEnd: () => moment().utc(), }, last1Hour: { label: 'Last 1 hour', getStart: () => moment().utc().subtract(1, 'hour'), - getEnd: () => moment().utc(), }, last6Hours: { label: 'Last 6 hours', getStart: () => moment().utc().subtract(6, 'hours'), - getEnd: () => moment().utc(), }, last24Hours: { label: 'Last 24 hours', getStart: () => moment().utc().subtract(24, 'hours'), - getEnd: () => moment().utc(), }, last7Days: { label: 'Last 7 days', getStart: () => moment().utc().subtract(7, 'days'), - getEnd: () => moment().utc(), }, last30Days: { label: 'Last 30 days', getStart: () => moment().utc().subtract(30, 'days'), - getEnd: () => moment().utc(), }, last90Days: { label: 'Last 90 days', getStart: () => moment().utc().subtract(90, 'days'), - getEnd: () => moment().utc(), }, last1Year: { label: 'Last 1 year', getStart: () => moment().subtract(1, 'year'), - getEnd: () => moment().utc(), }, todaySoFar: { label: 'Today so far', getStart: () => moment().utc().startOf('day'), - getEnd: () => moment().utc(), }, thisWeekSoFar: { label: 'This week so far', getStart: () => moment().utc().startOf('week'), - getEnd: () => moment().utc(), }, thisMonthSoFar: { label: 'This month so far', getStart: () => moment().utc().startOf('month'), - getEnd: () => moment().utc(), }, thisYearSoFar: { label: 'This year so far', getStart: () => moment().utc().startOf('year'), - getEnd: () => moment().utc(), }, - // yesterday: { - // label: 'Yesterday', - // getStart: () => moment().utc().subtract(1, 'day').startOf('day'), - // getEnd: () => moment().utc().subtract(1, 'day').endOf('day'), - // }, - // previousWeek: { - // label: 'Previous week', - // getStart: () => moment().utc().subtract(1, 'week').startOf('week'), - // getEnd: () => moment().utc().subtract(1, 'week').endOf('week'), - // }, - // previousMonth: { - // label: 'Previous month', - // getStart: () => moment().utc().subtract(1, 'month').startOf('month'), - // getEnd: () => moment().utc().subtract(1, 'month').endOf('month'), - // }, - // previousYear: { - // label: 'Previous year', - // getStart: () => moment().utc().subtract(1, 'year').startOf('year'), - // getEnd: () => moment().utc().subtract(1, 'year').endOf('year'), - // }, }; class TimelineControl extends React.Component { @@ -105,7 +73,7 @@ class TimelineControl extends React.Component { this.state = { showTimelinePanel: false, - offsetMilliseconds: 0, + millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }; @@ -130,42 +98,46 @@ class TimelineControl extends React.Component { this.setState({ showTimelinePanel: !this.state.showTimelinePanel }); } - handleSliderChange(value) { - const offsetMilliseconds = this.getRangeMilliseconds() - value; + handleSliderChange(sliderValue) { + const millisecondsInPast = this.getRangeMilliseconds() - sliderValue; this.props.startMovingInTime(); - this.debouncedUpdateTimestamp(offsetMilliseconds); - this.setState({ offsetMilliseconds }); + this.debouncedUpdateTimestamp(millisecondsInPast); + this.setState({ millisecondsInPast }); } - getRangeMilliseconds() { - const range = this.state.rangeOptionSelected; - return range.getEnd().diff(range.getStart()); + handleRangeOptionClick(rangeOption) { + this.setState({ rangeOptionSelected: rangeOption }); + + const rangeMilliseconds = this.getRangeMilliseconds(rangeOption); + if (this.state.millisecondsInPast > rangeMilliseconds) { + this.updateTimestamp(rangeMilliseconds); + this.setState({ millisecondsInPast: rangeMilliseconds }); + } + } + + getRangeMilliseconds(rangeOption) { + rangeOption = rangeOption || this.state.rangeOptionSelected; + return moment().diff(rangeOption.getStart()); } jumpToNow() { this.setState({ showTimelinePanel: false, - offsetMilliseconds: 0, + millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }); this.props.startMovingInTime(); this.updateTimestamp(null); } - getTotalOffset() { - const { rangeOptionSelected, offsetMilliseconds } = this.state; - const rangeBehindMilliseconds = moment().utc().diff(rangeOptionSelected.getEnd()); - return offsetMilliseconds + rangeBehindMilliseconds; - } - - renderRangeOption(option) { - const handleClick = () => { this.setState({ rangeOptionSelected: option }); }; - const selected = (this.state.rangeOptionSelected.label === option.label); + renderRangeOption(rangeOption) { + const handleClick = () => { this.handleRangeOptionClick(rangeOption); }; + const selected = (this.state.rangeOptionSelected.label === rangeOption.label); const className = classNames('option', { selected }); return ( - - {option.label} + + {rangeOption.label} ); } @@ -179,13 +151,13 @@ class TimelineControl extends React.Component { } renderTimelineSlider() { - const { offsetMilliseconds } = this.state; + const { millisecondsInPast } = this.state; const rangeMilliseconds = this.getRangeMilliseconds(); return ( ); @@ -193,9 +165,8 @@ class TimelineControl extends React.Component { render() { const { movingInTime } = this.props; - const { showTimelinePanel, offsetMilliseconds } = this.state; - - const showingCurrent = (this.getTotalOffset() === 0); + const { showTimelinePanel, millisecondsInPast } = this.state; + const isCurrent = (millisecondsInPast === 0); return (
@@ -230,10 +201,10 @@ class TimelineControl extends React.Component {
} - {!showingCurrent && this.renderJumpToNowButton()} + {!isCurrent && this.renderJumpToNowButton()}
diff --git a/client/app/scripts/components/topology-timestamp-button.js b/client/app/scripts/components/topology-timestamp-button.js index 727012d72c..46d6f91fbc 100644 --- a/client/app/scripts/components/topology-timestamp-button.js +++ b/client/app/scripts/components/topology-timestamp-button.js @@ -3,63 +3,44 @@ import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; +import { isPausedSelector } from '../selectors/timeline'; +import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; -const TIMESTAMP_TICK_INTERVAL = 500; class TopologyTimestampButton extends React.PureComponent { - constructor(props, context) { - super(props, context); - - this.state = this.getFreshState(); - } - componentDidMount() { this.timer = setInterval(() => { - if (!this.props.paused) { - this.setState(this.getFreshState()); + if (!this.props.isPaused) { + this.forceUpdate(); } - }, TIMESTAMP_TICK_INTERVAL); + }, TIMELINE_TICK_INTERVAL); } componentWillUnmount() { clearInterval(this.timer); } - getFreshState() { - const { updatePausedAt, offset } = this.props; - - let timestamp = updatePausedAt; - let showingCurrentState = false; - - if (!updatePausedAt) { - timestamp = moment().utc(); - showingCurrentState = true; - - if (offset >= 1000) { - timestamp = timestamp.subtract(offset); - showingCurrentState = false; - } - } - return { timestamp, showingCurrentState }; - } - renderTimestamp() { + const { isPaused, updatePausedAt, millisecondsInPast } = this.props; + const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast); + return ( - + ); } render() { - const { selected, onClick } = this.props; - const { showingCurrentState } = this.state; + const { selected, onClick, millisecondsInPast } = this.props; + const isCurrent = (millisecondsInPast === 0); + const className = classNames('button topology-timestamp-button', { - selected, current: showingCurrentState, + selected, current: isCurrent }); return ( - {showingCurrentState ? 'now' : this.renderTimestamp()} + {isCurrent ? 'now' : this.renderTimestamp()} @@ -69,6 +50,7 @@ class TopologyTimestampButton extends React.PureComponent { function mapStateToProps(state) { return { + isPaused: isPausedSelector(state), updatePausedAt: state.get('updatePausedAt'), }; } 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 83234b6181..e4b365b72d 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -1,6 +1,7 @@ /* Intervals in ms */ 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; @@ -8,3 +9,5 @@ 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; diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 1689ceda50..d2152c343c 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -22,7 +22,7 @@ import { isResourceViewModeSelector, } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; -import { consolidatedBeginningOfNodesDeltaBuffer } from '../utils/update-buffer-utils'; +import { consolidateNodesDeltas } from '../utils/nodes-delta-utils'; import { getWebsocketQueryTimestamp } from '../utils/web-api-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { @@ -88,7 +88,7 @@ export const initialState = makeMap({ topologyOptions: makeOrderedMap(), // topologyId -> options topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl topologyViewMode: GRAPH_VIEW_MODE, - updatePausedAt: null, // Date + updatePausedAt: null, // moment.js timestamp version: '...', versionUpdate: null, viewport: makeMap(), @@ -375,10 +375,10 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER: { - const deltaUnion = consolidatedBeginningOfNodesDeltaBuffer(state); - state = state.setIn(['nodesDeltaBuffer', 0], deltaUnion); - state = state.deleteIn(['nodesDeltaBuffer', 1]); - return state; + 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: { diff --git a/client/app/scripts/selectors/timeline.js b/client/app/scripts/selectors/timeline.js new file mode 100644 index 0000000000..1bb7b801e3 --- /dev/null +++ b/client/app/scripts/selectors/timeline.js @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; + + +export const isPausedSelector = createSelector( + [ + state => state.get('updatePausedAt') + ], + updatePausedAt => updatePausedAt !== null +); diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/nodes-delta-utils.js similarity index 68% rename from client/app/scripts/utils/update-buffer-utils.js rename to client/app/scripts/utils/nodes-delta-utils.js index 665a77480e..173a54504f 100644 --- a/client/app/scripts/utils/update-buffer-utils.js +++ b/client/app/scripts/utils/nodes-delta-utils.js @@ -1,22 +1,18 @@ import debug from 'debug'; import { union, size, map, find, reject, each } from 'lodash'; -const log = debug('scope:update-buffer-utils'); +const log = debug('scope:nodes-delta-utils'); -export function isNodesDeltaPaused(state) { - return state.get('updatePausedAt') !== null; -} - -// consolidate first buffer entry with second -export function consolidatedBeginningOfNodesDeltaBuffer(state) { - const first = state.getIn(['nodesDeltaBuffer', 0]); - const second = state.getIn(['nodesDeltaBuffer', 1]); +// TODO: 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)); + 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) => { @@ -51,8 +47,10 @@ export function consolidatedBeginningOfNodesDeltaBuffer(state) { // 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)); + log('Consolidated delta buffer', + 'add', size(toAdd), + 'update', size(toUpdate), + 'remove', size(toRemove)); return { add: toAdd.length > 0 ? toAdd : null, @@ -60,7 +58,3 @@ export function consolidatedBeginningOfNodesDeltaBuffer(state) { remove: toRemove.length > 0 ? toRemove : null }; } - -export function getUpdateBufferSize(state) { - return state.get('nodesDeltaBuffer').size; -} From fb08afdc11654972d7ef8cb1da08d40091cb57ed Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 7 Jun 2017 16:10:56 +0200 Subject: [PATCH 16/21] More polishing. --- client/app/scripts/actions/app-actions.js | 77 +++++++------------ client/app/scripts/components/nodes.js | 14 ++-- .../scripts/components/timeline-control.js | 76 +++++++++--------- client/app/scripts/constants/action-types.js | 52 ++++++------- client/app/scripts/reducers/root.js | 36 +++++---- client/app/scripts/utils/nodes-delta-utils.js | 2 +- client/app/scripts/utils/web-api-utils.js | 9 ++- client/app/styles/_base.scss | 6 +- 8 files changed, 124 insertions(+), 148 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d829ebffc3..255b3cd2d6 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -77,28 +77,10 @@ export function sortOrderChanged(sortedBy, sortedDesc) { } function resetNodesDeltaBuffer() { - clearTimeout(nodesDeltaBufferUpdateTimer); + clearInterval(nodesDeltaBufferUpdateTimer); return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; } -function bufferDeltaUpdate(delta) { - return (dispatch, getState) => { - if (delta.add === null && delta.update === null && delta.remove === null) { - log('Discarding empty nodes delta'); - return; - } - - if (getState().get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { - dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); - } - - dispatch({ - type: ActionTypes.ADD_TO_NODES_DELTA_BUFFER, - delta, - }); - log('Buffering node delta, new size', getState().get('nodesDeltaBuffer').size); - }; -} // // Networks @@ -443,23 +425,17 @@ export function clickTopology(topologyId) { }; } -export function startMovingInTime() { +export function startWebsocketTransition() { return { - type: ActionTypes.START_MOVING_IN_TIME, + type: ActionTypes.START_WEBSOCKET_TRANSITION, }; } -export function websocketQueryTimestamp(timestampSinceNow) { - // If the timestamp stands for a time less than one second ago, - // assume we are actually interested in the current time. - if (timestampSinceNow < 1000) { - timestampSinceNow = null; - } - +export function websocketQueryInPast(millisecondsInPast) { return (dispatch, getState) => { dispatch({ - type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - timestampSinceNow, + type: ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST, + millisecondsInPast, }); updateWebsocketChannel(getState(), dispatch); dispatch(resetNodesDeltaBuffer()); @@ -621,12 +597,15 @@ export function receiveNodesDelta(delta) { setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); const state = getState(); - const movingInTime = state.get('websocketMovingInTime'); + const movingInTime = state.get('websocketTransitioning'); const hasChanges = delta.add || delta.update || delta.remove; if (hasChanges || movingInTime) { if (isPausedSelector(state)) { - dispatch(bufferDeltaUpdate(delta)); + 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, @@ -637,24 +616,16 @@ export function receiveNodesDelta(delta) { }; } -function maybeUpdateFromNodesDeltaBuffer() { - return (dispatch, getState) => { - if (isPausedSelector(getState())) { - dispatch(resetNodesDeltaBuffer()); - } else { - if (!getState().get('nodesDeltaBuffer').isEmpty()) { - const delta = getState().get('nodesDeltaBuffer').first(); - dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); - dispatch(receiveNodesDelta(delta)); - } - if (!getState().get('nodesDeltaBuffer').isEmpty()) { - nodesDeltaBufferUpdateTimer = setTimeout( - () => dispatch(maybeUpdateFromNodesDeltaBuffer()), - NODES_DELTA_BUFFER_FEED_INTERVAL, - ); - } - } - }; +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() { @@ -662,7 +633,11 @@ export function clickResumeUpdate() { dispatch({ type: ActionTypes.CLICK_RESUME_UPDATE }); - dispatch(maybeUpdateFromNodesDeltaBuffer(getState)); + // Periodically merge buffered nodes deltas until the buffer is emptied. + nodesDeltaBufferUpdateTimer = setInterval( + () => updateFromNodesDeltaBuffer(dispatch, getState()), + NODES_DELTA_BUFFER_FEED_INTERVAL, + ); }; } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index a8fb1dd751..fea480ff94 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -34,11 +34,7 @@ const NODES_DISPLAY_EMPTY_CAUSES = [ ]; const renderCauses = causes => ( -
    - {causes.map(cause => ( -
  • {cause}
  • - ))} -
+
    {causes.map(cause =>
  • {cause}
  • )}
); class Nodes extends React.Component { @@ -56,9 +52,9 @@ class Nodes extends React.Component { render() { const { topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode, - isTableViewMode, isResourceViewMode, movingInTime } = this.props; + isTableViewMode, isResourceViewMode, websocketTransitioning } = this.props; - const className = classNames('nodes-wrapper', { blurred: movingInTime }); + const className = classNames('nodes-wrapper', { blurred: websocketTransitioning }); // TODO: Rename view mode components. return ( @@ -89,9 +85,9 @@ function mapStateToProps(state) { topologyNodeCountZero: isTopologyNodeCountZero(state), nodesDisplayEmpty: isNodesDisplayEmpty(state), topologyEmpty: isTopologyEmpty(state), - movingInTime: state.get('websocketMovingInTime'), + websocketTransitioning: state.get('websocketTransitioning'), currentTopology: state.get('currentTopology'), - nodesLoaded: state.get('nodesLoaded') || state.get('websocketMovingInTime'), + nodesLoaded: state.get('nodesLoaded'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), }; diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 813c33559f..8206114cbb 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -8,9 +8,9 @@ import { debounce } from 'lodash'; import PauseButton from './pause-button'; import TopologyTimestampButton from './topology-timestamp-button'; import { - websocketQueryTimestamp, + websocketQueryInPast, + startWebsocketTransition, clickResumeUpdate, - startMovingInTime, } from '../actions/app-actions'; import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; @@ -72,37 +72,28 @@ class TimelineControl extends React.Component { super(props, context); this.state = { - showTimelinePanel: false, + showSliderPanel: false, millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }; - this.jumpToNow = this.jumpToNow.bind(this); - this.toggleTimelinePanel = this.toggleTimelinePanel.bind(this); - this.handleSliderChange = this.handleSliderChange.bind(this); 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); } componentWillUnmount() { - this.updateTimestamp(null); - } - - updateTimestamp(timestampSinceNow) { - this.props.websocketQueryTimestamp(timestampSinceNow); - this.props.clickResumeUpdate(); - } - - toggleTimelinePanel() { - this.setState({ showTimelinePanel: !this.state.showTimelinePanel }); + this.updateTimestamp(); } handleSliderChange(sliderValue) { const millisecondsInPast = this.getRangeMilliseconds() - sliderValue; - this.props.startMovingInTime(); - this.debouncedUpdateTimestamp(millisecondsInPast); this.setState({ millisecondsInPast }); + this.debouncedUpdateTimestamp(millisecondsInPast); + this.props.startWebsocketTransition(); } handleRangeOptionClick(rangeOption) { @@ -110,24 +101,33 @@ class TimelineControl extends React.Component { const rangeMilliseconds = this.getRangeMilliseconds(rangeOption); if (this.state.millisecondsInPast > rangeMilliseconds) { - this.updateTimestamp(rangeMilliseconds); this.setState({ millisecondsInPast: rangeMilliseconds }); + this.updateTimestamp(rangeMilliseconds); + this.props.startWebsocketTransition(); } } - getRangeMilliseconds(rangeOption) { - rangeOption = rangeOption || this.state.rangeOptionSelected; - return moment().diff(rangeOption.getStart()); - } - - jumpToNow() { + handleJumpToNowClick() { this.setState({ - showTimelinePanel: false, + showSliderPanel: false, millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }); - this.props.startMovingInTime(); - this.updateTimestamp(null); + this.updateTimestamp(); + this.props.startWebsocketTransition(); + } + + handleTimestampClick() { + this.setState({ showSliderPanel: !this.state.showSliderPanel }); + } + + updateTimestamp(millisecondsInPast = 0) { + this.props.websocketQueryInPast(millisecondsInPast); + this.props.clickResumeUpdate(); + } + + getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) { + return moment().diff(rangeOption.getStart()); } renderRangeOption(rangeOption) { @@ -144,7 +144,7 @@ class TimelineControl extends React.Component { renderJumpToNowButton() { return ( - + ); @@ -164,13 +164,13 @@ class TimelineControl extends React.Component { } render() { - const { movingInTime } = this.props; - const { showTimelinePanel, millisecondsInPast } = this.state; + const { websocketTransitioning } = this.props; + const { showSliderPanel, millisecondsInPast } = this.state; const isCurrent = (millisecondsInPast === 0); return (
- {showTimelinePanel &&
+ {showSliderPanel &&
Explore
@@ -196,13 +196,13 @@ class TimelineControl extends React.Component { {this.renderTimelineSlider()}
}
- {movingInTime &&
+ {websocketTransitioning &&
} {!isCurrent && this.renderJumpToNowButton()} @@ -214,15 +214,15 @@ class TimelineControl extends React.Component { function mapStateToProps(state) { return { - movingInTime: state.get('websocketMovingInTime'), + websocketTransitioning: state.get('websocketTransitioning'), }; } export default connect( mapStateToProps, { - websocketQueryTimestamp, + websocketQueryInPast, + startWebsocketTransition, clickResumeUpdate, - startMovingInTime, } )(TimelineControl); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 817a0559f1..5edd000ac0 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,60 +21,57 @@ const ACTION_TYPES = [ 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', - 'START_MOVING_IN_TIME', - 'WEBSOCKET_QUERY_TIMESTAMP', - 'CLEAR_NODES_DELTA_BUFFER', 'CONSOLIDATE_NODES_DELTA_BUFFER', - 'ADD_TO_NODES_DELTA_BUFFER', - 'POP_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', + '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/reducers/root.js b/client/app/scripts/reducers/root.js index d2152c343c..4369f742ac 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -23,7 +23,6 @@ import { } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { consolidateNodesDeltas } from '../utils/nodes-delta-utils'; -import { getWebsocketQueryTimestamp } from '../utils/web-api-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -93,8 +92,8 @@ export const initialState = makeMap({ versionUpdate: null, viewport: makeMap(), websocketClosed: false, - websocketMovingInTime: false, - websocketQueryTimestampSinceNow: null, + websocketTransitioning: false, + websocketQueryMillisecondsInPast: 0, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -297,9 +296,8 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_PAUSE_UPDATE: { - const pausedAt = state.get('websocketQueryTimestampSinceNow') ? - moment(getWebsocketQueryTimestamp(state)) : moment().utc(); - return state.set('updatePausedAt', pausedAt); + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast)); } case ActionTypes.CLICK_RELATIVE: { @@ -354,12 +352,20 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.START_MOVING_IN_TIME: { - return state.set('websocketMovingInTime', true); + // + // websockets + // + + case ActionTypes.OPEN_WEBSOCKET: { + return state.set('websocketClosed', false); } - case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { - return state.set('websocketQueryTimestampSinceNow', action.timestampSinceNow); + case ActionTypes.START_WEBSOCKET_TRANSITION: { + return state.set('websocketTransitioning', true); + } + + case ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST: { + return state.set('websocketQueryMillisecondsInPast', action.millisecondsInPast); } case ActionTypes.CLOSE_WEBSOCKET: { @@ -385,7 +391,7 @@ export function rootReducer(state = initialState, action) { return state.update('nodesDeltaBuffer', buffer => buffer.shift()); } - case ActionTypes.ADD_TO_NODES_DELTA_BUFFER: { + case ActionTypes.BUFFER_NODES_DELTA: { return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta)); } @@ -522,10 +528,6 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.OPEN_WEBSOCKET: { - return state.set('websocketClosed', false); - } - case ActionTypes.DO_CONTROL_ERROR: { return state.setIn(['controlStatus', action.nodeId], makeMap({ pending: false, @@ -614,8 +616,8 @@ export function rootReducer(state = initialState, action) { state = state.set('errorUrl', null); - if (state.get('websocketMovingInTime')) { - state = state.set('websocketMovingInTime', false); + if (state.get('websocketTransitioning')) { + state = state.set('websocketTransitioning', false); state = clearNodes(state); } diff --git a/client/app/scripts/utils/nodes-delta-utils.js b/client/app/scripts/utils/nodes-delta-utils.js index 173a54504f..0c1f01983c 100644 --- a/client/app/scripts/utils/nodes-delta-utils.js +++ b/client/app/scripts/utils/nodes-delta-utils.js @@ -4,7 +4,7 @@ import { union, size, map, find, reject, each } from 'lodash'; const log = debug('scope:nodes-delta-utils'); -// TODO: Would be nice to have a unit test for this function. +// 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); diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 7eae675963..25d3c0ea03 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -237,9 +237,12 @@ export function getTopologies(options, dispatch, initialPoll) { }); } -export function getWebsocketQueryTimestamp(state) { - const sinceNow = state.get('websocketQueryTimestampSinceNow'); - return sinceNow ? moment().utc().subtract(sinceNow).toISOString() : null; +function getWebsocketQueryTimestamp(state) { + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + // The timestamp query parameter will be used only if it's in the past. + if (millisecondsInPast === 0) return null; + + return moment().utc().subtract(millisecondsInPast).toISOString(); } export function updateWebsocketChannel(state, dispatch) { diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 26c5a542fa..f458095af1 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -82,7 +82,7 @@ } &.active { - @extend .blinkable; + & > * { @extend .blinkable; } border: 1px solid $text-tertiary-color; } } @@ -236,12 +236,12 @@ .button { margin-left: 0.5em; } .topology-timestamp-button:not(.current) { - @extend .blinkable; + & > * { @extend .blinkable; } font-weight: bold; } } - .timeline-panel { + .slider-panel { width: 355px; .caption, .slider-tip { From 1b93699ccfa81009cd912b80234e52ddbcf08092 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 7 Jun 2017 17:22:40 +0200 Subject: [PATCH 17/21] Final refactoring. --- client/app/scripts/components/status.js | 16 +++++++--------- client/app/scripts/reducers/root.js | 6 +++++- client/app/scripts/selectors/timeline.js | 7 +++++++ client/app/scripts/utils/topology-utils.js | 4 +++- client/app/scripts/utils/web-api-utils.js | 6 ++++-- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index d9c98677a8..fc0219a296 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,6 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/timeline'; + class Status extends React.Component { render() { @@ -25,13 +27,9 @@ class Status extends React.Component { showWarningIcon = true; } else if (topology) { const stats = topology.get('stats'); - if (showingCurrentState) { - text = `${stats.get('node_count') - filteredNodeCount} nodes`; - if (stats.get('filtered_nodes')) { - text = `${text} (${stats.get('filtered_nodes') + filteredNodeCount} filtered)`; - } - } else { - text = ''; + text = `${stats.get('node_count') - filteredNodeCount} nodes`; + if (stats.get('filtered_nodes')) { + text = `${text} (${stats.get('filtered_nodes') + filteredNodeCount} filtered)`; } classNames += ' status-stats'; showWarningIcon = false; @@ -40,7 +38,7 @@ class Status extends React.Component { return (
{showWarningIcon && } - {text} + {showingCurrentState && text}
); } @@ -50,7 +48,7 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, - showingCurrentState: !state.get('websocketTimestampOffset'), + showingCurrentState: isWebsocketQueryingCurrentSelector(state), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), websocketClosed: state.get('websocketClosed'), diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 4369f742ac..2503880e78 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -87,7 +87,7 @@ export const initialState = makeMap({ topologyOptions: makeOrderedMap(), // topologyId -> options topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl topologyViewMode: GRAPH_VIEW_MODE, - updatePausedAt: null, // moment.js timestamp + updatePausedAt: null, version: '...', versionUpdate: null, viewport: makeMap(), @@ -616,6 +616,10 @@ export function rootReducer(state = initialState, action) { 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); diff --git a/client/app/scripts/selectors/timeline.js b/client/app/scripts/selectors/timeline.js index 1bb7b801e3..ae84cb43d8 100644 --- a/client/app/scripts/selectors/timeline.js +++ b/client/app/scripts/selectors/timeline.js @@ -7,3 +7,10 @@ export const isPausedSelector = createSelector( ], updatePausedAt => updatePausedAt !== null ); + +export const isWebsocketQueryingCurrentSelector = createSelector( + [ + state => state.get('websocketQueryMillisecondsInPast') + ], + websocketQueryMillisecondsInPast => websocketQueryMillisecondsInPast === 0 +); diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index f829bcbe27..40020711f8 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/timeline'; import { isResourceViewModeSelector } from '../selectors/topology'; import { pinnedMetricSelector } from '../selectors/node-metric'; @@ -134,7 +135,8 @@ export function getCurrentTopologyOptions(state) { } export function isTopologyNodeCountZero(state) { - return state.getIn(['currentTopology', 'stats', 'node_count'], 0) === 0; + const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0); + return nodeCount === 0 && isWebsocketQueryingCurrentSelector(state); } export function isNodesDisplayEmpty(state) { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 25d3c0ea03..bfc5784902 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -13,6 +13,7 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr import { getCurrentTopologyUrl } from '../utils/topology-utils'; import { layersTopologyIdsSelector } from '../selectors/resource-view/layout'; import { activeTopologyOptionsSelector } from '../selectors/topology'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/timeline'; import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer'; const log = debug('scope:web-api-utils'); @@ -48,6 +49,7 @@ let continuePolling = true; 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)) { @@ -238,10 +240,10 @@ export function getTopologies(options, dispatch, initialPoll) { } function getWebsocketQueryTimestamp(state) { - const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); // The timestamp query parameter will be used only if it's in the past. - if (millisecondsInPast === 0) return null; + if (isWebsocketQueryingCurrentSelector(state)) return null; + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); return moment().utc().subtract(millisecondsInPast).toISOString(); } From e9c9cf749a524a58717d64466d1142493dae4ec2 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 8 Jun 2017 11:41:51 +0200 Subject: [PATCH 18/21] Addressed a couple of bugs. --- client/app/scripts/components/nodes.js | 2 +- client/app/scripts/components/status.js | 7 ++++++- .../scripts/components/timeline-control.js | 21 +++++++++++++++++-- client/app/scripts/constants/timer.js | 1 + client/app/styles/_base.scss | 2 +- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index fea480ff94..53cd063f32 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -34,7 +34,7 @@ const NODES_DISPLAY_EMPTY_CAUSES = [ ]; const renderCauses = causes => ( -
    {causes.map(cause =>
  • {cause}
  • )}
+
    {causes.map(cause =>
  • {cause}
  • )}
); class Nodes extends React.Component { diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index fc0219a296..9be10cae41 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -33,12 +33,17 @@ 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 (
{showWarningIcon && } - {showingCurrentState && text} + {text}
); } diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 8206114cbb..85ac92809e 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -13,7 +13,10 @@ import { clickResumeUpdate, } from '../actions/app-actions'; -import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; +import { + TIMELINE_SLIDER_UPDATE_INTERVAL, + TIMELINE_DEBOUNCE_INTERVAL, +} from '../constants/timer'; const sliderRanges = { @@ -85,12 +88,26 @@ class TimelineControl extends React.Component { this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); } + componentWillMount() { + // 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) { - const millisecondsInPast = this.getRangeMilliseconds() - 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.debouncedUpdateTimestamp(millisecondsInPast); this.props.startWebsocketTransition(); diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index e4b365b72d..fdb9d2e069 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -11,3 +11,4 @@ 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/styles/_base.scss b/client/app/styles/_base.scss index f458095af1..a10e0a48f6 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -217,7 +217,7 @@ .timeline-control { @extend .overlay-wrapper; display: block; - right: 500px; + right: 530px; .time-status { display: flex; From 5abaa8a58c28b752d77d5c5b9ebc3676be33c28f Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 8 Jun 2017 16:16:04 +0200 Subject: [PATCH 19/21] Hidden the timeline control behind Cloud context and a feature flag. --- client/app/scripts/actions/app-actions.js | 16 +++++++++++----- client/app/scripts/components/app.js | 5 ++++- client/app/scripts/components/pause-button.js | 10 +++++----- .../app/scripts/components/timeline-control.js | 12 +++++++++--- .../components/topology-timestamp-button.js | 8 ++++---- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 255b3cd2d6..377711ba61 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -432,12 +432,13 @@ export function startWebsocketTransition() { } export function websocketQueryInPast(millisecondsInPast) { - return (dispatch, getState) => { + return (dispatch, getServiceState) => { dispatch({ type: ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST, millisecondsInPast, }); - updateWebsocketChannel(getState(), dispatch); + const scopeState = getServiceState().scope; + updateWebsocketChannel(scopeState, dispatch); dispatch(resetNodesDeltaBuffer()); }; } @@ -596,7 +597,12 @@ export function receiveNodesDelta(delta) { // setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); - const state = getState(); + // 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; @@ -629,13 +635,13 @@ function updateFromNodesDeltaBuffer(dispatch, state) { } export function clickResumeUpdate() { - return (dispatch, getState) => { + return (dispatch, getServiceState) => { dispatch({ type: ActionTypes.CLICK_RESUME_UPDATE }); // Periodically merge buffered nodes deltas until the buffer is emptied. nodesDeltaBufferUpdateTimer = setInterval( - () => updateFromNodesDeltaBuffer(dispatch, getState()), + () => updateFromNodesDeltaBuffer(dispatch, getServiceState().scope), NODES_DELTA_BUFFER_FEED_INTERVAL, ); }; diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0dc389be65..187b67d7da 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, @@ -191,7 +192,9 @@ class App extends React.Component { - {!isResourceViewMode && } + + {!isResourceViewMode && } + {showingNetworkSelector && isGraphViewMode && } diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index 328334d19e..f840aadb7c 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -35,12 +35,12 @@ class PauseButton extends React.Component { } } -function mapStateToProps(state) { +function mapStateToProps({ scope }) { return { - hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), - updateCount: state.get('nodesDeltaBuffer').size, - updatePausedAt: state.get('updatePausedAt'), - isPaused: isPausedSelector(state), + hasUpdates: !scope.get('nodesDeltaBuffer').isEmpty(), + updateCount: scope.get('nodesDeltaBuffer').size, + updatePausedAt: scope.get('updatePausedAt'), + isPaused: isPausedSelector(scope), }; } diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 85ac92809e..f34036c2be 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -181,10 +181,13 @@ class TimelineControl extends React.Component { } render() { - const { websocketTransitioning } = this.props; + const { websocketTransitioning, hasTimelineControl } = this.props; const { showSliderPanel, millisecondsInPast } = this.state; const isCurrent = (millisecondsInPast === 0); + // Don't render the timeline control if it's not explicitly enabled for this instance. + if (!hasTimelineControl) return null; + return (
{showSliderPanel &&
@@ -229,9 +232,12 @@ class TimelineControl extends React.Component { } } -function mapStateToProps(state) { +function mapStateToProps({ scope, root }, { params }) { + const cloudInstance = root.instances[params.orgId] || {}; + const featureFlags = cloudInstance.featureFlags || []; return { - websocketTransitioning: state.get('websocketTransitioning'), + hasTimelineControl: featureFlags.includes('timeline-control'), + websocketTransitioning: scope.get('websocketTransitioning'), }; } diff --git a/client/app/scripts/components/topology-timestamp-button.js b/client/app/scripts/components/topology-timestamp-button.js index 46d6f91fbc..e9cef663ce 100644 --- a/client/app/scripts/components/topology-timestamp-button.js +++ b/client/app/scripts/components/topology-timestamp-button.js @@ -7,7 +7,7 @@ import { isPausedSelector } from '../selectors/timeline'; import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; -class TopologyTimestampButton extends React.PureComponent { +class TopologyTimestampButton extends React.Component { componentDidMount() { this.timer = setInterval(() => { if (!this.props.isPaused) { @@ -48,10 +48,10 @@ class TopologyTimestampButton extends React.PureComponent { } } -function mapStateToProps(state) { +function mapStateToProps({ scope }) { return { - isPaused: isPausedSelector(state), - updatePausedAt: state.get('updatePausedAt'), + isPaused: isPausedSelector(scope), + updatePausedAt: scope.get('updatePausedAt'), }; } From 150a6c9efd6c7824674660d875c9e93b5e9a2fee Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Fri, 9 Jun 2017 17:51:17 +0200 Subject: [PATCH 20/21] Addressed most of @davkal's comments. --- client/app/scripts/actions/app-actions.js | 6 +-- client/app/scripts/components/app.js | 4 +- client/app/scripts/components/nodes.js | 2 +- client/app/scripts/components/pause-button.js | 2 +- client/app/scripts/components/status.js | 2 +- ...amp-button.js => time-travel-timestamp.js} | 12 ++--- .../{timeline-control.js => time-travel.js} | 46 +++++++++---------- client/app/scripts/constants/action-types.js | 2 +- client/app/scripts/reducers/root.js | 2 +- .../selectors/{timeline.js => time-travel.js} | 0 client/app/scripts/utils/topology-utils.js | 2 +- client/app/scripts/utils/web-api-utils.js | 2 +- client/app/styles/_base.scss | 17 ++++--- 13 files changed, 49 insertions(+), 50 deletions(-) rename client/app/scripts/components/{topology-timestamp-button.js => time-travel-timestamp.js} (75%) rename client/app/scripts/components/{timeline-control.js => time-travel.js} (85%) rename client/app/scripts/selectors/{timeline.js => time-travel.js} (100%) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 377711ba61..83465b63c5 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -17,7 +17,7 @@ import { } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; -import { isPausedSelector } from '../selectors/timeline'; +import { isPausedSelector } from '../selectors/time-travel'; import { availableMetricTypesSelector, nextPinnedMetricTypeSelector, @@ -425,9 +425,9 @@ export function clickTopology(topologyId) { }; } -export function startWebsocketTransition() { +export function startWebsocketTransitionLoader() { return { - type: ActionTypes.START_WEBSOCKET_TRANSITION, + type: ActionTypes.START_WEBSOCKET_TRANSITION_LOADER, }; } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 187b67d7da..6bd8ab7cc9 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -30,7 +30,7 @@ import { } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; -import TimelineControl from './timeline-control'; +import TimeTravel from './time-travel'; import ViewModeSelector from './view-mode-selector'; import NetworkSelector from './networks-selector'; import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar'; @@ -193,7 +193,7 @@ class App extends React.Component { - {!isResourceViewMode && } + {!isResourceViewMode && } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 53cd063f32..efc4824188 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -30,7 +30,7 @@ const NODE_COUNT_ZERO_CAUSES = [ ]; const NODES_DISPLAY_EMPTY_CAUSES = [ 'There are nodes, but they\'re currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.', - 'There are no nodes for this particular moment in time. Use the timeline feature at the bottom-right corner to explore different times.', + 'There are no nodes for this particular moment in time. Use the time travel feature at the bottom-right corner to explore different times.', ]; const renderCauses = causes => ( diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index f840aadb7c..0c1c12b86d 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -3,7 +3,7 @@ import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import { isPausedSelector } from '../selectors/timeline'; +import { isPausedSelector } from '../selectors/time-travel'; import { clickPauseUpdate, clickResumeUpdate } from '../actions/app-actions'; diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index 9be10cae41..cb0c0861d3 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { isWebsocketQueryingCurrentSelector } from '../selectors/timeline'; +import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel'; class Status extends React.Component { diff --git a/client/app/scripts/components/topology-timestamp-button.js b/client/app/scripts/components/time-travel-timestamp.js similarity index 75% rename from client/app/scripts/components/topology-timestamp-button.js rename to client/app/scripts/components/time-travel-timestamp.js index e9cef663ce..5b44a6e4e8 100644 --- a/client/app/scripts/components/topology-timestamp-button.js +++ b/client/app/scripts/components/time-travel-timestamp.js @@ -3,11 +3,11 @@ import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import { isPausedSelector } from '../selectors/timeline'; +import { isPausedSelector } from '../selectors/time-travel'; import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; -class TopologyTimestampButton extends React.Component { +class TimeTravelTimestamp extends React.Component { componentDidMount() { this.timer = setInterval(() => { if (!this.props.isPaused) { @@ -25,7 +25,7 @@ class TopologyTimestampButton extends React.Component { const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast); return ( - + ); } @@ -33,13 +33,13 @@ class TopologyTimestampButton extends React.Component { const { selected, onClick, millisecondsInPast } = this.props; const isCurrent = (millisecondsInPast === 0); - const className = classNames('button topology-timestamp-button', { + const className = classNames('button time-travel-timestamp', { selected, current: isCurrent }); return ( - + {isCurrent ? 'now' : this.renderTimestamp()} @@ -55,4 +55,4 @@ function mapStateToProps({ scope }) { }; } -export default connect(mapStateToProps)(TopologyTimestampButton); +export default connect(mapStateToProps)(TimeTravelTimestamp); diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/time-travel.js similarity index 85% rename from client/app/scripts/components/timeline-control.js rename to client/app/scripts/components/time-travel.js index f34036c2be..1fea49f8da 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/time-travel.js @@ -6,10 +6,10 @@ import { connect } from 'react-redux'; import { debounce } from 'lodash'; import PauseButton from './pause-button'; -import TopologyTimestampButton from './topology-timestamp-button'; +import TimeTravelTimestamp from './time-travel-timestamp'; import { websocketQueryInPast, - startWebsocketTransition, + startWebsocketTransitionLoader, clickResumeUpdate, } from '../actions/app-actions'; @@ -70,7 +70,7 @@ const sliderRanges = { }, }; -class TimelineControl extends React.Component { +class TimeTravel extends React.Component { constructor(props, context) { super(props, context); @@ -88,7 +88,7 @@ class TimelineControl extends React.Component { this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); } - componentWillMount() { + componentDidMount() { // Force periodic re-renders to update the slider position as time goes by. this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL); } @@ -110,7 +110,7 @@ class TimelineControl extends React.Component { this.setState({ millisecondsInPast }); this.debouncedUpdateTimestamp(millisecondsInPast); - this.props.startWebsocketTransition(); + this.props.startWebsocketTransitionLoader(); } handleRangeOptionClick(rangeOption) { @@ -120,7 +120,7 @@ class TimelineControl extends React.Component { if (this.state.millisecondsInPast > rangeMilliseconds) { this.setState({ millisecondsInPast: rangeMilliseconds }); this.updateTimestamp(rangeMilliseconds); - this.props.startWebsocketTransition(); + this.props.startWebsocketTransitionLoader(); } } @@ -131,7 +131,7 @@ class TimelineControl extends React.Component { rangeOptionSelected: sliderRanges.last1Hour, }); this.updateTimestamp(); - this.props.startWebsocketTransition(); + this.props.startWebsocketTransitionLoader(); } handleTimestampClick() { @@ -167,7 +167,7 @@ class TimelineControl extends React.Component { ); } - renderTimelineSlider() { + renderTimeSlider() { const { millisecondsInPast } = this.state; const rangeMilliseconds = this.getRangeMilliseconds(); @@ -181,17 +181,17 @@ class TimelineControl extends React.Component { } render() { - const { websocketTransitioning, hasTimelineControl } = this.props; - const { showSliderPanel, millisecondsInPast } = this.state; + const { websocketTransitioning, hasTimeTravel } = this.props; + const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state; + const lowerCaseLabel = rangeOptionSelected.label.toLowerCase(); const isCurrent = (millisecondsInPast === 0); - // Don't render the timeline control if it's not explicitly enabled for this instance. - if (!hasTimelineControl) return null; + // Don't render the time travel control if it's not explicitly enabled for this instance. + if (!hasTimeTravel) return null; return ( -
- {showSliderPanel &&
- Explore +
+ {showSliderPanel &&
{this.renderRangeOption(sliderRanges.last15Minutes)} @@ -212,14 +212,14 @@ class TimelineControl extends React.Component { {this.renderRangeOption(sliderRanges.thisYearSoFar)}
- Move the slider to travel back in time - {this.renderTimelineSlider()} + Move the slider to explore {lowerCaseLabel} + {this.renderTimeSlider()}
} -
- {websocketTransitioning &&
+
+ {websocketTransitioning &&
} - * { @extend .blinkable; } font-weight: bold; } } - .slider-panel { + &-slider { width: 355px; - .caption, .slider-tip { + .slider-tip { display: inline-block; font-size: 0.8125rem; + font-style: italic; padding: 5px 10px; } - .slider-tip { font-style: italic; } - .options { display: flex; padding: 2px 0 10px; From 3d3fa67937b14bfbac1372b0bfc09313137f467c Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Fri, 9 Jun 2017 18:31:00 +0200 Subject: [PATCH 21/21] Added mixpanel tracking. --- client/app/scripts/components/pause-button.js | 31 +++++++++++++- client/app/scripts/components/time-travel.js | 41 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index 0c1c12b86d..7b88f323fe 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -4,13 +4,38 @@ import classNames from 'classnames'; import { connect } from 'react-redux'; import { isPausedSelector } from '../selectors/time-travel'; +import { trackMixpanelEvent } from '../utils/tracking-utils'; import { clickPauseUpdate, clickResumeUpdate } from '../actions/app-actions'; class PauseButton extends React.Component { + constructor(props, context) { + super(props, context); + + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + if (this.props.isPaused) { + trackMixpanelEvent('scope.time.resume.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + nodesDeltaBufferSize: this.props.updateCount, + }); + this.props.clickResumeUpdate(); + } else { + trackMixpanelEvent('scope.time.pause.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + this.props.clickPauseUpdate(); + } + } + render() { const { isPaused, hasUpdates, updateCount, updatePausedAt } = this.props; - const action = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; const className = classNames('button pause-button', { active: isPaused }); const title = isPaused ? @@ -27,7 +52,7 @@ class PauseButton extends React.Component { } return ( -
+ {label !== '' && {label}} @@ -40,6 +65,8 @@ function mapStateToProps({ scope }) { 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), }; } diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 1fea49f8da..cb206acf41 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -7,6 +7,7 @@ import { debounce } from 'lodash'; import PauseButton from './pause-button'; import TimeTravelTimestamp from './time-travel-timestamp'; +import { trackMixpanelEvent } from '../utils/tracking-utils'; import { websocketQueryInPast, startWebsocketTransitionLoader, @@ -84,8 +85,11 @@ class TimeTravel extends React.Component { 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() { @@ -109,8 +113,10 @@ class TimeTravel extends React.Component { } this.setState({ millisecondsInPast }); - this.debouncedUpdateTimestamp(millisecondsInPast); this.props.startWebsocketTransitionLoader(); + this.debouncedUpdateTimestamp(millisecondsInPast); + + this.debouncedTrackSliderChange(); } handleRangeOptionClick(rangeOption) { @@ -122,6 +128,13 @@ class TimeTravel extends React.Component { 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() { @@ -132,10 +145,24 @@ class TimeTravel extends React.Component { }); 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() { - this.setState({ showSliderPanel: !this.state.showSliderPanel }); + 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) { @@ -147,6 +174,14 @@ class TimeTravel extends React.Component { 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); @@ -238,6 +273,8 @@ function mapStateToProps({ scope, root }, { params }) { return { hasTimeTravel: featureFlags.includes('timeline-control'), websocketTransitioning: scope.get('websocketTransitioning'), + topologyViewMode: scope.get('topologyViewMode'), + currentTopology: scope.get('currentTopology'), }; }