mirror of
https://github.com/weaveworks/scope.git
synced 2026-02-14 18:09:59 +00:00
Time travel control (#2524)
* Hacky working prototype. * Operate with time.Duration offset instead of fixed timestamp. * Polished the backend code. * Made a nicer UI component. * Small refactorings of the websockets code. * Fixed the backend tests. * Better websocketing and smoother transitions * Small styling refactoring. * Detecting empty topologies. * Improved error messaging. * Addressed some of David's comments. * Moved nodesDeltaBuffer to a global state to fix the paused status rendering bug. * Small styling changes * Changed the websocket global state variables a bit. * Polishing & refactoring. * More polishing. * Final refactoring. * Addressed a couple of bugs. * Hidden the timeline control behind Cloud context and a feature flag. * Addressed most of @davkal's comments. * Added mixpanel tracking.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,16 +93,33 @@ func handleWebsocket(
|
||||
}(conn)
|
||||
|
||||
var (
|
||||
previousTopo detailed.NodeSummaries
|
||||
tick = time.Tick(loop)
|
||||
wait = make(chan struct{}, 1)
|
||||
topologyID = mux.Vars(r)["topology"]
|
||||
previousTopo detailed.NodeSummaries
|
||||
tick = time.Tick(loop)
|
||||
wait = make(chan struct{}, 1)
|
||||
topologyID = mux.Vars(r)["topology"]
|
||||
channelOpenedAt = time.Now()
|
||||
// By default we will always be reporting the most recent state.
|
||||
startReportingAt = time.Now()
|
||||
)
|
||||
|
||||
// If the timestamp is provided explicitly by the UI, we start reporting from there.
|
||||
if timestampStr := r.Form.Get("timestamp"); timestampStr != "" {
|
||||
startReportingAt, _ = time.Parse(time.RFC3339, timestampStr)
|
||||
}
|
||||
|
||||
rep.WaitOn(ctx, wait)
|
||||
defer rep.UnWait(ctx, wait)
|
||||
|
||||
for {
|
||||
report, err := rep.Report(ctx)
|
||||
// We measure how much time has passed since the channel was opened
|
||||
// and add it to the initial report timestamp to get the timestamp
|
||||
// of the snapshot we want to report right now.
|
||||
// NOTE: Multiplying `timestampDelta` by a constant factor here
|
||||
// would have an effect of fast-forward, which is something we
|
||||
// might be interested in implementing in the future.
|
||||
timestampDelta := time.Since(channelOpenedAt)
|
||||
reportTimestamp := startReportingAt.Add(timestampDelta)
|
||||
report, err := rep.Report(ctx, reportTimestamp)
|
||||
if err != nil {
|
||||
log.Errorf("Error generating report: %v", err)
|
||||
return
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const reportQuantisationInterval = 3 * time.Second
|
||||
// Reporter is something that can produce reports on demand. It's a convenient
|
||||
// interface for parts of the app, and several experimental components.
|
||||
type Reporter interface {
|
||||
Report(context.Context) (report.Report, error)
|
||||
Report(context.Context, time.Time) (report.Report, error)
|
||||
WaitOn(context.Context, chan struct{})
|
||||
UnWait(context.Context, chan struct{})
|
||||
}
|
||||
@@ -118,14 +118,14 @@ func (c *collector) Add(_ context.Context, rpt report.Report, _ []byte) error {
|
||||
|
||||
// Report returns a merged report over all added reports. It implements
|
||||
// Reporter.
|
||||
func (c *collector) Report(_ context.Context) (report.Report, error) {
|
||||
func (c *collector) Report(_ context.Context, timestamp time.Time) (report.Report, error) {
|
||||
c.mtx.Lock()
|
||||
defer c.mtx.Unlock()
|
||||
|
||||
// If the oldest report is still within range,
|
||||
// and there is a cached report, return that.
|
||||
if c.cached != nil && len(c.reports) > 0 {
|
||||
oldest := mtime.Now().Add(-c.window)
|
||||
oldest := timestamp.Add(-c.window)
|
||||
if c.timestamps[0].After(oldest) {
|
||||
return *c.cached, nil
|
||||
}
|
||||
@@ -191,7 +191,9 @@ type StaticCollector report.Report
|
||||
|
||||
// Report returns a merged report over all added reports. It implements
|
||||
// Reporter.
|
||||
func (c StaticCollector) Report(context.Context) (report.Report, error) { return report.Report(c), nil }
|
||||
func (c StaticCollector) Report(context.Context, time.Time) (report.Report, error) {
|
||||
return report.Report(c), nil
|
||||
}
|
||||
|
||||
// Add adds a report to the collector's internal state. It implements Adder.
|
||||
func (c StaticCollector) Add(context.Context, report.Report, []byte) error { return nil }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -297,12 +297,13 @@ func (c *awsCollector) getReports(ctx context.Context, reportKeys []string) ([]r
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
func (c *awsCollector) Report(ctx context.Context) (report.Report, error) {
|
||||
func (c *awsCollector) Report(ctx context.Context, timestamp time.Time) (report.Report, error) {
|
||||
var (
|
||||
now = time.Now()
|
||||
start = now.Add(-c.window)
|
||||
rowStart, rowEnd = start.UnixNano() / time.Hour.Nanoseconds(), now.UnixNano() / time.Hour.Nanoseconds()
|
||||
userid, err = c.userIDer(ctx)
|
||||
end = timestamp
|
||||
start = end.Add(-c.window)
|
||||
rowStart = start.UnixNano() / time.Hour.Nanoseconds()
|
||||
rowEnd = end.UnixNano() / time.Hour.Nanoseconds()
|
||||
userid, err = c.userIDer(ctx)
|
||||
)
|
||||
if err != nil {
|
||||
return report.MakeReport(), err
|
||||
@@ -311,12 +312,12 @@ func (c *awsCollector) Report(ctx context.Context) (report.Report, error) {
|
||||
// Queries will only every span 2 rows max.
|
||||
var reportKeys []string
|
||||
if rowStart != rowEnd {
|
||||
reportKeys1, err := c.getReportKeys(ctx, userid, rowStart, start, now)
|
||||
reportKeys1, err := c.getReportKeys(ctx, userid, rowStart, start, end)
|
||||
if err != nil {
|
||||
return report.MakeReport(), err
|
||||
}
|
||||
|
||||
reportKeys2, err := c.getReportKeys(ctx, userid, rowEnd, start, now)
|
||||
reportKeys2, err := c.getReportKeys(ctx, userid, rowEnd, start, end)
|
||||
if err != nil {
|
||||
return report.MakeReport(), err
|
||||
}
|
||||
@@ -324,12 +325,12 @@ func (c *awsCollector) Report(ctx context.Context) (report.Report, error) {
|
||||
reportKeys = append(reportKeys, reportKeys1...)
|
||||
reportKeys = append(reportKeys, reportKeys2...)
|
||||
} else {
|
||||
if reportKeys, err = c.getReportKeys(ctx, userid, rowEnd, start, now); err != nil {
|
||||
if reportKeys, err = c.getReportKeys(ctx, userid, rowEnd, start, end); err != nil {
|
||||
return report.MakeReport(), err
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("Fetching %d reports from %v to %v", len(reportKeys), start, now)
|
||||
log.Debugf("Fetching %d reports from %v to %v", len(reportKeys), start, end)
|
||||
reports, err := c.getReports(ctx, reportKeys)
|
||||
if err != nil {
|
||||
return report.MakeReport(), err
|
||||
|
||||
@@ -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, capabilities map[string]bool) 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import debug from 'debug';
|
||||
import find from 'lodash/find';
|
||||
import { find } from 'lodash';
|
||||
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { saveGraph } from '../utils/file-utils';
|
||||
import { updateRoute } from '../utils/router-utils';
|
||||
import {
|
||||
bufferDeltaUpdate,
|
||||
resumeUpdate,
|
||||
resetUpdateBuffer,
|
||||
} from '../utils/update-buffer-utils';
|
||||
import {
|
||||
doControlRequest,
|
||||
getAllNodes,
|
||||
getResourceViewNodesSnapshot,
|
||||
getNodesDelta,
|
||||
updateWebsocketChannel,
|
||||
getNodeDetails,
|
||||
getTopologies,
|
||||
deletePipe,
|
||||
stopPolling,
|
||||
teardownWebsockets,
|
||||
} from '../utils/web-api-utils';
|
||||
import { getCurrentTopologyUrl } from '../utils/topology-utils';
|
||||
import { storageSet } from '../utils/storage-utils';
|
||||
import { loadTheme } from '../utils/contrast-utils';
|
||||
import { isPausedSelector } from '../selectors/time-travel';
|
||||
import {
|
||||
availableMetricTypesSelector,
|
||||
nextPinnedMetricTypeSelector,
|
||||
@@ -34,15 +29,21 @@ import {
|
||||
isResourceViewModeSelector,
|
||||
resourceViewAvailableSelector,
|
||||
} from '../selectors/topology';
|
||||
|
||||
import { NODES_DELTA_BUFFER_SIZE_LIMIT } from '../constants/limits';
|
||||
import { NODES_DELTA_BUFFER_FEED_INTERVAL } from '../constants/timer';
|
||||
import {
|
||||
GRAPH_VIEW_MODE,
|
||||
TABLE_VIEW_MODE,
|
||||
RESOURCE_VIEW_MODE,
|
||||
} from '../constants/naming';
|
||||
} from '../constants/naming';
|
||||
|
||||
|
||||
const log = debug('scope:app-actions');
|
||||
|
||||
// TODO: This shouldn't be exposed here as a global variable.
|
||||
let nodesDeltaBufferUpdateTimer = null;
|
||||
|
||||
export function showHelp() {
|
||||
return { type: ActionTypes.SHOW_HELP };
|
||||
}
|
||||
@@ -75,6 +76,11 @@ export function sortOrderChanged(sortedBy, sortedDesc) {
|
||||
};
|
||||
}
|
||||
|
||||
function resetNodesDeltaBuffer() {
|
||||
clearInterval(nodesDeltaBufferUpdateTimer);
|
||||
return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER };
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Networks
|
||||
@@ -211,14 +217,10 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) {
|
||||
});
|
||||
updateRoute(getState);
|
||||
// update all request workers with new options
|
||||
resetUpdateBuffer();
|
||||
dispatch(resetNodesDeltaBuffer());
|
||||
const state = getState();
|
||||
getTopologies(activeTopologyOptionsSelector(state), dispatch);
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateWebsocketChannel(state, dispatch);
|
||||
getNodeDetails(
|
||||
state.get('topologyUrlsById'),
|
||||
state.get('currentTopologyId'),
|
||||
@@ -387,15 +389,6 @@ export function clickRelative(nodeId, topologyId, label, origin) {
|
||||
};
|
||||
}
|
||||
|
||||
export function clickResumeUpdate() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.CLICK_RESUME_UPDATE
|
||||
});
|
||||
resumeUpdate(getState);
|
||||
};
|
||||
}
|
||||
|
||||
function updateTopology(dispatch, getState) {
|
||||
const state = getState();
|
||||
// If we're in the resource view, get the snapshot of all the relevant node topologies.
|
||||
@@ -404,15 +397,11 @@ function updateTopology(dispatch, getState) {
|
||||
}
|
||||
updateRoute(getState);
|
||||
// update all request workers with new options
|
||||
resetUpdateBuffer();
|
||||
dispatch(resetNodesDeltaBuffer());
|
||||
// NOTE: This is currently not needed for our static resource
|
||||
// view, but we'll need it here later and it's simpler to just
|
||||
// keep it than to redo the nodes delta updating logic.
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateWebsocketChannel(state, dispatch);
|
||||
}
|
||||
|
||||
export function clickShowTopologyForNode(topologyId, nodeId) {
|
||||
@@ -436,6 +425,24 @@ export function clickTopology(topologyId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function startWebsocketTransitionLoader() {
|
||||
return {
|
||||
type: ActionTypes.START_WEBSOCKET_TRANSITION_LOADER,
|
||||
};
|
||||
}
|
||||
|
||||
export function websocketQueryInPast(millisecondsInPast) {
|
||||
return (dispatch, getServiceState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST,
|
||||
millisecondsInPast,
|
||||
});
|
||||
const scopeState = getServiceState().scope;
|
||||
updateWebsocketChannel(scopeState, dispatch);
|
||||
dispatch(resetNodesDeltaBuffer());
|
||||
};
|
||||
}
|
||||
|
||||
export function cacheZoomState(zoomState) {
|
||||
return {
|
||||
type: ActionTypes.CACHE_ZOOM_STATE,
|
||||
@@ -590,10 +597,21 @@ export function receiveNodesDelta(delta) {
|
||||
//
|
||||
setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0);
|
||||
|
||||
if (delta.add || delta.update || delta.remove) {
|
||||
const state = getState();
|
||||
if (state.get('updatePausedAt') !== null) {
|
||||
bufferDeltaUpdate(delta);
|
||||
// TODO: This way of getting the Scope state is a bit hacky, so try to replace
|
||||
// it with something better. The problem is that all the actions that are called
|
||||
// from the components wrapped in <CloudFeature /> have a global Service state
|
||||
// returned by getState(). Since method is called from both contexts, getState()
|
||||
// will sometimes return Scope state subtree and sometimes the whole Service state.
|
||||
const state = getState().scope || getState();
|
||||
const movingInTime = state.get('websocketTransitioning');
|
||||
const hasChanges = delta.add || delta.update || delta.remove;
|
||||
|
||||
if (hasChanges || movingInTime) {
|
||||
if (isPausedSelector(state)) {
|
||||
if (state.get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) {
|
||||
dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER });
|
||||
}
|
||||
dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta });
|
||||
} else {
|
||||
dispatch({
|
||||
type: ActionTypes.RECEIVE_NODES_DELTA,
|
||||
@@ -604,6 +622,31 @@ export function receiveNodesDelta(delta) {
|
||||
};
|
||||
}
|
||||
|
||||
function updateFromNodesDeltaBuffer(dispatch, state) {
|
||||
const isPaused = isPausedSelector(state);
|
||||
const isBufferEmpty = state.get('nodesDeltaBuffer').isEmpty();
|
||||
|
||||
if (isPaused || isBufferEmpty) {
|
||||
dispatch(resetNodesDeltaBuffer());
|
||||
} else {
|
||||
dispatch(receiveNodesDelta(state.get('nodesDeltaBuffer').first()));
|
||||
dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER });
|
||||
}
|
||||
}
|
||||
|
||||
export function clickResumeUpdate() {
|
||||
return (dispatch, getServiceState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.CLICK_RESUME_UPDATE
|
||||
});
|
||||
// Periodically merge buffered nodes deltas until the buffer is emptied.
|
||||
nodesDeltaBufferUpdateTimer = setInterval(
|
||||
() => updateFromNodesDeltaBuffer(dispatch, getServiceState().scope),
|
||||
NODES_DELTA_BUFFER_FEED_INTERVAL,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function receiveNodesForTopology(nodes, topologyId) {
|
||||
return {
|
||||
type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY,
|
||||
@@ -620,11 +663,7 @@ export function receiveTopologies(topologies) {
|
||||
topologies
|
||||
});
|
||||
const state = getState();
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateWebsocketChannel(state, dispatch);
|
||||
getNodeDetails(
|
||||
state.get('topologyUrlsById'),
|
||||
state.get('currentTopologyId'),
|
||||
@@ -741,11 +780,7 @@ export function route(urlState) {
|
||||
// update all request workers with new options
|
||||
const state = getState();
|
||||
getTopologies(activeTopologyOptionsSelector(state), dispatch);
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateWebsocketChannel(state, dispatch);
|
||||
getNodeDetails(
|
||||
state.get('topologyUrlsById'),
|
||||
state.get('currentTopologyId'),
|
||||
|
||||
@@ -12,6 +12,7 @@ import Search from './search';
|
||||
import Status from './status';
|
||||
import Topologies from './topologies';
|
||||
import TopologyOptions from './topology-options';
|
||||
import CloudFeature from './cloud-feature';
|
||||
import { getApiDetails, getTopologies } from '../utils/web-api-utils';
|
||||
import {
|
||||
focusSearch,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '../actions/app-actions';
|
||||
import Details from './details';
|
||||
import Nodes from './nodes';
|
||||
import TimeTravel from './time-travel';
|
||||
import ViewModeSelector from './view-mode-selector';
|
||||
import NetworkSelector from './networks-selector';
|
||||
import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar';
|
||||
@@ -190,6 +192,10 @@ class App extends React.Component {
|
||||
|
||||
<Nodes />
|
||||
|
||||
<CloudFeature>
|
||||
{!isResourceViewMode && <TimeTravel />}
|
||||
</CloudFeature>
|
||||
|
||||
<Sidebar classNames={isTableViewMode ? 'sidebar-gridmode' : ''}>
|
||||
{showingNetworkSelector && isGraphViewMode && <NetworkSelector />}
|
||||
{!isResourceViewMode && <Status />}
|
||||
|
||||
@@ -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 (
|
||||
<div className="footer">
|
||||
|
||||
<div className="footer-status">
|
||||
{versionUpdate && <a
|
||||
className="footer-versionupdate"
|
||||
@@ -89,10 +65,6 @@ class Footer extends React.Component {
|
||||
</div>
|
||||
|
||||
<div className="footer-tools">
|
||||
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
|
||||
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
|
||||
<span className="fa fa-pause" />
|
||||
</a>
|
||||
<a
|
||||
className="footer-icon"
|
||||
onClick={this.handleRelayoutClick}
|
||||
@@ -122,7 +94,6 @@ class Footer extends React.Component {
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
hostname: state.get('hostname'),
|
||||
updatePausedAt: state.get('updatePausedAt'),
|
||||
topologyViewMode: state.get('topologyViewMode'),
|
||||
version: state.get('version'),
|
||||
versionUpdate: state.get('versionUpdate'),
|
||||
@@ -135,8 +106,6 @@ export default connect(
|
||||
{
|
||||
clickDownloadGraph,
|
||||
clickForceRelayout,
|
||||
clickPauseUpdate,
|
||||
clickResumeUpdate,
|
||||
toggleHelp,
|
||||
toggleTroubleshootingMenu,
|
||||
setContrastMode
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import NodesChart from '../charts/nodes-chart';
|
||||
@@ -7,43 +8,65 @@ 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 EmptyTopologyError = show => (
|
||||
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
|
||||
<div className="heading">Nothing to show. This can have any of these reasons:</div>
|
||||
<ul>
|
||||
<li>We haven't received any reports from probes recently.
|
||||
Are the probes properly configured?</li>
|
||||
<li>There are nodes, but they're currently hidden. Check the view options
|
||||
in the bottom-left if they allow for showing hidden nodes.</li>
|
||||
<li>Containers view only: you're not running Docker,
|
||||
or you don't have any containers.</li>
|
||||
</ul>
|
||||
</NodesError>
|
||||
|
||||
// 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',
|
||||
];
|
||||
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 time travel feature at the bottom-right corner to explore different times.',
|
||||
];
|
||||
|
||||
const renderCauses = causes => (
|
||||
<ul>{causes.map(cause => <li key={cause}>{cause}</li>)}</ul>
|
||||
);
|
||||
|
||||
class Nodes extends React.Component {
|
||||
renderConditionalEmptyTopologyError() {
|
||||
const { topologyNodeCountZero, nodesDisplayEmpty, topologyEmpty } = this.props;
|
||||
|
||||
return (
|
||||
<NodesError faIconClass="fa-circle-thin" hidden={!topologyEmpty}>
|
||||
<div className="heading">Nothing to show. This can have any of these reasons:</div>
|
||||
{topologyNodeCountZero && renderCauses(NODE_COUNT_ZERO_CAUSES)}
|
||||
{!topologyNodeCountZero && nodesDisplayEmpty && renderCauses(NODES_DISPLAY_EMPTY_CAUSES)}
|
||||
</NodesError>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { topologyEmpty, topologiesLoaded, nodesLoaded, topologies, currentTopology,
|
||||
isGraphViewMode, isTableViewMode, isResourceViewMode } = this.props;
|
||||
const { topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode,
|
||||
isTableViewMode, isResourceViewMode, websocketTransitioning } = this.props;
|
||||
|
||||
const className = classNames('nodes-wrapper', { blurred: websocketTransitioning });
|
||||
|
||||
// TODO: Rename view mode components.
|
||||
return (
|
||||
<div className="nodes-wrapper">
|
||||
<DelayedShow delay={1000} show={!topologiesLoaded || (topologiesLoaded && !nodesLoaded)}>
|
||||
<div className={className}>
|
||||
<DelayedShow delay={TOPOLOGY_LOADER_DELAY} show={!topologiesLoaded || !nodesLoaded}>
|
||||
<Loading itemType="topologies" show={!topologiesLoaded} />
|
||||
<Loading
|
||||
itemType={getNodeType(currentTopology, topologies)}
|
||||
show={topologiesLoaded && !nodesLoaded} />
|
||||
</DelayedShow>
|
||||
{EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
|
||||
|
||||
{topologiesLoaded && nodesLoaded && this.renderConditionalEmptyTopologyError()}
|
||||
|
||||
{isGraphViewMode && <NodesChart />}
|
||||
{isTableViewMode && <NodesGrid />}
|
||||
@@ -59,11 +82,14 @@ function mapStateToProps(state) {
|
||||
isGraphViewMode: isGraphViewModeSelector(state),
|
||||
isTableViewMode: isTableViewModeSelector(state),
|
||||
isResourceViewMode: isResourceViewModeSelector(state),
|
||||
topologyNodeCountZero: isTopologyNodeCountZero(state),
|
||||
nodesDisplayEmpty: isNodesDisplayEmpty(state),
|
||||
topologyEmpty: isTopologyEmpty(state),
|
||||
websocketTransitioning: state.get('websocketTransitioning'),
|
||||
currentTopology: state.get('currentTopology'),
|
||||
nodesLoaded: state.get('nodesLoaded'),
|
||||
topologies: state.get('topologies'),
|
||||
topologiesLoaded: state.get('topologiesLoaded'),
|
||||
topologyEmpty: isTopologyEmpty(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
80
client/app/scripts/components/pause-button.js
Normal file
80
client/app/scripts/components/pause-button.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
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 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}`;
|
||||
} else if (hasUpdates && !isPaused) {
|
||||
label = `Resuming +${updateCount}`;
|
||||
} else if (!hasUpdates && isPaused) {
|
||||
label = 'Paused';
|
||||
}
|
||||
|
||||
return (
|
||||
<a className={className} onClick={this.handleClick} title={title}>
|
||||
{label !== '' && <span className="pause-text">{label}</span>}
|
||||
<span className="fa fa-pause" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps({ scope }) {
|
||||
return {
|
||||
hasUpdates: !scope.get('nodesDeltaBuffer').isEmpty(),
|
||||
updateCount: scope.get('nodesDeltaBuffer').size,
|
||||
updatePausedAt: scope.get('updatePausedAt'),
|
||||
topologyViewMode: scope.get('topologyViewMode'),
|
||||
currentTopology: scope.get('currentTopology'),
|
||||
isPaused: isPausedSelector(scope),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
clickPauseUpdate,
|
||||
clickResumeUpdate,
|
||||
}
|
||||
)(PauseButton);
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
|
||||
|
||||
|
||||
class Status extends React.Component {
|
||||
render() {
|
||||
const {errorUrl, topologiesLoaded, filteredNodeCount, topology, websocketClosed} = this.props;
|
||||
const { errorUrl, topologiesLoaded, filteredNodeCount, topology,
|
||||
websocketClosed, showingCurrentState } = this.props;
|
||||
|
||||
let title = '';
|
||||
let text = 'Trying to reconnect...';
|
||||
@@ -29,6 +33,11 @@ class Status extends React.Component {
|
||||
}
|
||||
classNames += ' status-stats';
|
||||
showWarningIcon = false;
|
||||
// TODO: Currently the stats are always pulled for the current state of the system,
|
||||
// so they are incorrect when showing the past. This should be addressed somehow.
|
||||
if (!showingCurrentState) {
|
||||
text = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,9 +53,10 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
errorUrl: state.get('errorUrl'),
|
||||
filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size,
|
||||
showingCurrentState: isWebsocketQueryingCurrentSelector(state),
|
||||
topologiesLoaded: state.get('topologiesLoaded'),
|
||||
topology: state.get('currentTopology'),
|
||||
websocketClosed: state.get('websocketClosed')
|
||||
websocketClosed: state.get('websocketClosed'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
58
client/app/scripts/components/time-travel-timestamp.js
Normal file
58
client/app/scripts/components/time-travel-timestamp.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { isPausedSelector } from '../selectors/time-travel';
|
||||
import { TIMELINE_TICK_INTERVAL } from '../constants/timer';
|
||||
|
||||
|
||||
class TimeTravelTimestamp extends React.Component {
|
||||
componentDidMount() {
|
||||
this.timer = setInterval(() => {
|
||||
if (!this.props.isPaused) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
}, TIMELINE_TICK_INTERVAL);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
renderTimestamp() {
|
||||
const { isPaused, updatePausedAt, millisecondsInPast } = this.props;
|
||||
const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast);
|
||||
|
||||
return (
|
||||
<time>{timestamp.format()}</time>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected, onClick, millisecondsInPast } = this.props;
|
||||
const isCurrent = (millisecondsInPast === 0);
|
||||
|
||||
const className = classNames('button time-travel-timestamp', {
|
||||
selected, current: isCurrent
|
||||
});
|
||||
|
||||
return (
|
||||
<a className={className} onClick={onClick}>
|
||||
<span className="time-travel-timestamp-info">
|
||||
{isCurrent ? 'now' : this.renderTimestamp()}
|
||||
</span>
|
||||
<span className="fa fa-clock-o" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps({ scope }) {
|
||||
return {
|
||||
isPaused: isPausedSelector(scope),
|
||||
updatePausedAt: scope.get('updatePausedAt'),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(TimeTravelTimestamp);
|
||||
288
client/app/scripts/components/time-travel.js
Normal file
288
client/app/scripts/components/time-travel.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import Slider from 'rc-slider';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import PauseButton from './pause-button';
|
||||
import TimeTravelTimestamp from './time-travel-timestamp';
|
||||
import { trackMixpanelEvent } from '../utils/tracking-utils';
|
||||
import {
|
||||
websocketQueryInPast,
|
||||
startWebsocketTransitionLoader,
|
||||
clickResumeUpdate,
|
||||
} from '../actions/app-actions';
|
||||
|
||||
import {
|
||||
TIMELINE_SLIDER_UPDATE_INTERVAL,
|
||||
TIMELINE_DEBOUNCE_INTERVAL,
|
||||
} from '../constants/timer';
|
||||
|
||||
|
||||
const sliderRanges = {
|
||||
last15Minutes: {
|
||||
label: 'Last 15 minutes',
|
||||
getStart: () => moment().utc().subtract(15, 'minutes'),
|
||||
},
|
||||
last1Hour: {
|
||||
label: 'Last 1 hour',
|
||||
getStart: () => moment().utc().subtract(1, 'hour'),
|
||||
},
|
||||
last6Hours: {
|
||||
label: 'Last 6 hours',
|
||||
getStart: () => moment().utc().subtract(6, 'hours'),
|
||||
},
|
||||
last24Hours: {
|
||||
label: 'Last 24 hours',
|
||||
getStart: () => moment().utc().subtract(24, 'hours'),
|
||||
},
|
||||
last7Days: {
|
||||
label: 'Last 7 days',
|
||||
getStart: () => moment().utc().subtract(7, 'days'),
|
||||
},
|
||||
last30Days: {
|
||||
label: 'Last 30 days',
|
||||
getStart: () => moment().utc().subtract(30, 'days'),
|
||||
},
|
||||
last90Days: {
|
||||
label: 'Last 90 days',
|
||||
getStart: () => moment().utc().subtract(90, 'days'),
|
||||
},
|
||||
last1Year: {
|
||||
label: 'Last 1 year',
|
||||
getStart: () => moment().subtract(1, 'year'),
|
||||
},
|
||||
todaySoFar: {
|
||||
label: 'Today so far',
|
||||
getStart: () => moment().utc().startOf('day'),
|
||||
},
|
||||
thisWeekSoFar: {
|
||||
label: 'This week so far',
|
||||
getStart: () => moment().utc().startOf('week'),
|
||||
},
|
||||
thisMonthSoFar: {
|
||||
label: 'This month so far',
|
||||
getStart: () => moment().utc().startOf('month'),
|
||||
},
|
||||
thisYearSoFar: {
|
||||
label: 'This year so far',
|
||||
getStart: () => moment().utc().startOf('year'),
|
||||
},
|
||||
};
|
||||
|
||||
class TimeTravel extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
showSliderPanel: false,
|
||||
millisecondsInPast: 0,
|
||||
rangeOptionSelected: sliderRanges.last1Hour,
|
||||
};
|
||||
|
||||
this.renderRangeOption = this.renderRangeOption.bind(this);
|
||||
this.handleTimestampClick = this.handleTimestampClick.bind(this);
|
||||
this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this);
|
||||
this.handleSliderChange = this.handleSliderChange.bind(this);
|
||||
|
||||
this.debouncedUpdateTimestamp = debounce(
|
||||
this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
|
||||
this.debouncedTrackSliderChange = debounce(
|
||||
this.trackSliderChange.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Force periodic re-renders to update the slider position as time goes by.
|
||||
this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
this.updateTimestamp();
|
||||
}
|
||||
|
||||
handleSliderChange(sliderValue) {
|
||||
let millisecondsInPast = this.getRangeMilliseconds() - sliderValue;
|
||||
|
||||
// If the slider value is less than 1s away from the right-end (current time),
|
||||
// assume we meant the current time - this is important for the '... so far'
|
||||
// ranges where the range of values changes over time.
|
||||
if (millisecondsInPast < 1000) {
|
||||
millisecondsInPast = 0;
|
||||
}
|
||||
|
||||
this.setState({ millisecondsInPast });
|
||||
this.props.startWebsocketTransitionLoader();
|
||||
this.debouncedUpdateTimestamp(millisecondsInPast);
|
||||
|
||||
this.debouncedTrackSliderChange();
|
||||
}
|
||||
|
||||
handleRangeOptionClick(rangeOption) {
|
||||
this.setState({ rangeOptionSelected: rangeOption });
|
||||
|
||||
const rangeMilliseconds = this.getRangeMilliseconds(rangeOption);
|
||||
if (this.state.millisecondsInPast > rangeMilliseconds) {
|
||||
this.setState({ millisecondsInPast: rangeMilliseconds });
|
||||
this.updateTimestamp(rangeMilliseconds);
|
||||
this.props.startWebsocketTransitionLoader();
|
||||
}
|
||||
|
||||
trackMixpanelEvent('scope.time.range.select', {
|
||||
layout: this.props.topologyViewMode,
|
||||
topologyId: this.props.currentTopology.get('id'),
|
||||
parentTopologyId: this.props.currentTopology.get('parentId'),
|
||||
label: rangeOption.label,
|
||||
});
|
||||
}
|
||||
|
||||
handleJumpToNowClick() {
|
||||
this.setState({
|
||||
showSliderPanel: false,
|
||||
millisecondsInPast: 0,
|
||||
rangeOptionSelected: sliderRanges.last1Hour,
|
||||
});
|
||||
this.updateTimestamp();
|
||||
this.props.startWebsocketTransitionLoader();
|
||||
|
||||
trackMixpanelEvent('scope.time.now.click', {
|
||||
layout: this.props.topologyViewMode,
|
||||
topologyId: this.props.currentTopology.get('id'),
|
||||
parentTopologyId: this.props.currentTopology.get('parentId'),
|
||||
});
|
||||
}
|
||||
|
||||
handleTimestampClick() {
|
||||
const showSliderPanel = !this.state.showSliderPanel;
|
||||
this.setState({ showSliderPanel });
|
||||
|
||||
trackMixpanelEvent('scope.time.timestamp.click', {
|
||||
layout: this.props.topologyViewMode,
|
||||
topologyId: this.props.currentTopology.get('id'),
|
||||
parentTopologyId: this.props.currentTopology.get('parentId'),
|
||||
showSliderPanel,
|
||||
});
|
||||
}
|
||||
|
||||
updateTimestamp(millisecondsInPast = 0) {
|
||||
this.props.websocketQueryInPast(millisecondsInPast);
|
||||
this.props.clickResumeUpdate();
|
||||
}
|
||||
|
||||
getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) {
|
||||
return moment().diff(rangeOption.getStart());
|
||||
}
|
||||
|
||||
trackSliderChange() {
|
||||
trackMixpanelEvent('scope.time.slider.change', {
|
||||
layout: this.props.topologyViewMode,
|
||||
topologyId: this.props.currentTopology.get('id'),
|
||||
parentTopologyId: this.props.currentTopology.get('parentId'),
|
||||
});
|
||||
}
|
||||
|
||||
renderRangeOption(rangeOption) {
|
||||
const handleClick = () => { this.handleRangeOptionClick(rangeOption); };
|
||||
const selected = (this.state.rangeOptionSelected.label === rangeOption.label);
|
||||
const className = classNames('option', { selected });
|
||||
|
||||
return (
|
||||
<a key={rangeOption.label} className={className} onClick={handleClick}>
|
||||
{rangeOption.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderJumpToNowButton() {
|
||||
return (
|
||||
<a className="button jump-to-now" title="Jump to now" onClick={this.handleJumpToNowClick}>
|
||||
<span className="fa fa-step-forward" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderTimeSlider() {
|
||||
const { millisecondsInPast } = this.state;
|
||||
const rangeMilliseconds = this.getRangeMilliseconds();
|
||||
|
||||
return (
|
||||
<Slider
|
||||
onChange={this.handleSliderChange}
|
||||
value={rangeMilliseconds - millisecondsInPast}
|
||||
max={rangeMilliseconds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { websocketTransitioning, hasTimeTravel } = this.props;
|
||||
const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state;
|
||||
const lowerCaseLabel = rangeOptionSelected.label.toLowerCase();
|
||||
const isCurrent = (millisecondsInPast === 0);
|
||||
|
||||
// Don't render the time travel control if it's not explicitly enabled for this instance.
|
||||
if (!hasTimeTravel) return null;
|
||||
|
||||
return (
|
||||
<div className="time-travel">
|
||||
{showSliderPanel && <div className="time-travel-slider">
|
||||
<div className="options">
|
||||
<div className="column">
|
||||
{this.renderRangeOption(sliderRanges.last15Minutes)}
|
||||
{this.renderRangeOption(sliderRanges.last1Hour)}
|
||||
{this.renderRangeOption(sliderRanges.last6Hours)}
|
||||
{this.renderRangeOption(sliderRanges.last24Hours)}
|
||||
</div>
|
||||
<div className="column">
|
||||
{this.renderRangeOption(sliderRanges.last7Days)}
|
||||
{this.renderRangeOption(sliderRanges.last30Days)}
|
||||
{this.renderRangeOption(sliderRanges.last90Days)}
|
||||
{this.renderRangeOption(sliderRanges.last1Year)}
|
||||
</div>
|
||||
<div className="column">
|
||||
{this.renderRangeOption(sliderRanges.todaySoFar)}
|
||||
{this.renderRangeOption(sliderRanges.thisWeekSoFar)}
|
||||
{this.renderRangeOption(sliderRanges.thisMonthSoFar)}
|
||||
{this.renderRangeOption(sliderRanges.thisYearSoFar)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="slider-tip">Move the slider to explore {lowerCaseLabel}</span>
|
||||
{this.renderTimeSlider()}
|
||||
</div>}
|
||||
<div className="time-travel-status">
|
||||
{websocketTransitioning && <div className="time-travel-jump-loader">
|
||||
<span className="fa fa-circle-o-notch fa-spin" />
|
||||
</div>}
|
||||
<TimeTravelTimestamp
|
||||
onClick={this.handleTimestampClick}
|
||||
millisecondsInPast={millisecondsInPast}
|
||||
selected={showSliderPanel}
|
||||
/>
|
||||
{!isCurrent && this.renderJumpToNowButton()}
|
||||
<PauseButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps({ scope, root }, { params }) {
|
||||
const cloudInstance = root.instances[params.orgId] || {};
|
||||
const featureFlags = cloudInstance.featureFlags || [];
|
||||
return {
|
||||
hasTimeTravel: featureFlags.includes('timeline-control'),
|
||||
websocketTransitioning: scope.get('websocketTransitioning'),
|
||||
topologyViewMode: scope.get('topologyViewMode'),
|
||||
currentTopology: scope.get('currentTopology'),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
websocketQueryInPast,
|
||||
startWebsocketTransitionLoader,
|
||||
clickResumeUpdate,
|
||||
}
|
||||
)(TimeTravel);
|
||||
@@ -3,9 +3,12 @@ import { zipObject } from 'lodash';
|
||||
const ACTION_TYPES = [
|
||||
'ADD_QUERY_FILTER',
|
||||
'BLUR_SEARCH',
|
||||
'BUFFER_NODES_DELTA',
|
||||
'CACHE_ZOOM_STATE',
|
||||
'CHANGE_INSTANCE',
|
||||
'CHANGE_TOPOLOGY_OPTION',
|
||||
'CLEAR_CONTROL_ERROR',
|
||||
'CLEAR_NODES_DELTA_BUFFER',
|
||||
'CLICK_BACKGROUND',
|
||||
'CLICK_CLOSE_DETAILS',
|
||||
'CLICK_CLOSE_TERMINAL',
|
||||
@@ -18,54 +21,57 @@ const ACTION_TYPES = [
|
||||
'CLICK_TERMINAL',
|
||||
'CLICK_TOPOLOGY',
|
||||
'CLOSE_WEBSOCKET',
|
||||
'CONSOLIDATE_NODES_DELTA_BUFFER',
|
||||
'DEBUG_TOOLBAR_INTERFERING',
|
||||
'DESELECT_NODE',
|
||||
'DO_CONTROL',
|
||||
'DO_CONTROL_ERROR',
|
||||
'DO_CONTROL_SUCCESS',
|
||||
'DO_CONTROL',
|
||||
'DO_SEARCH',
|
||||
'ENTER_EDGE',
|
||||
'ENTER_NODE',
|
||||
'FOCUS_SEARCH',
|
||||
'HIDE_HELP',
|
||||
'HOVER_METRIC',
|
||||
'UNHOVER_METRIC',
|
||||
'LEAVE_EDGE',
|
||||
'LEAVE_NODE',
|
||||
'PIN_METRIC',
|
||||
'PIN_SEARCH',
|
||||
'UNPIN_METRIC',
|
||||
'UNPIN_SEARCH',
|
||||
'OPEN_WEBSOCKET',
|
||||
'PIN_METRIC',
|
||||
'PIN_NETWORK',
|
||||
'PIN_SEARCH',
|
||||
'POP_NODES_DELTA_BUFFER',
|
||||
'RECEIVE_API_DETAILS',
|
||||
'RECEIVE_CONTROL_NODE_REMOVED',
|
||||
'RECEIVE_CONTROL_PIPE',
|
||||
'RECEIVE_CONTROL_PIPE_STATUS',
|
||||
'RECEIVE_CONTROL_PIPE',
|
||||
'RECEIVE_ERROR',
|
||||
'RECEIVE_NODE_DETAILS',
|
||||
'RECEIVE_NODES',
|
||||
'RECEIVE_NODES_DELTA',
|
||||
'RECEIVE_NODES_FOR_TOPOLOGY',
|
||||
'RECEIVE_NODES',
|
||||
'RECEIVE_NOT_FOUND',
|
||||
'RECEIVE_SERVICE_IMAGES',
|
||||
'RECEIVE_TOPOLOGIES',
|
||||
'RECEIVE_API_DETAILS',
|
||||
'RECEIVE_ERROR',
|
||||
'REQUEST_SERVICE_IMAGES',
|
||||
'RESET_LOCAL_VIEW_STATE',
|
||||
'ROUTE_TOPOLOGY',
|
||||
'SHOW_HELP',
|
||||
'SET_VIEWPORT_DIMENSIONS',
|
||||
'SET_EXPORTING_GRAPH',
|
||||
'SELECT_NETWORK',
|
||||
'TOGGLE_TROUBLESHOOTING_MENU',
|
||||
'PIN_NETWORK',
|
||||
'UNPIN_NETWORK',
|
||||
'SHOW_NETWORKS',
|
||||
'SET_EXPORTING_GRAPH',
|
||||
'SET_RECEIVED_NODES_DELTA',
|
||||
'SORT_ORDER_CHANGED',
|
||||
'SET_VIEW_MODE',
|
||||
'CHANGE_INSTANCE',
|
||||
'TOGGLE_CONTRAST_MODE',
|
||||
'SET_VIEWPORT_DIMENSIONS',
|
||||
'SHOW_HELP',
|
||||
'SHOW_NETWORKS',
|
||||
'SHUTDOWN',
|
||||
'REQUEST_SERVICE_IMAGES',
|
||||
'RECEIVE_SERVICE_IMAGES'
|
||||
'SORT_ORDER_CHANGED',
|
||||
'START_WEBSOCKET_TRANSITION_LOADER',
|
||||
'TOGGLE_CONTRAST_MODE',
|
||||
'TOGGLE_TROUBLESHOOTING_MENU',
|
||||
'UNHOVER_METRIC',
|
||||
'UNPIN_METRIC',
|
||||
'UNPIN_NETWORK',
|
||||
'UNPIN_SEARCH',
|
||||
'WEBSOCKET_QUERY_MILLISECONDS_IN_PAST',
|
||||
];
|
||||
|
||||
export default zipObject(ACTION_TYPES, ACTION_TYPES);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
|
||||
export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5;
|
||||
export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/* Intervals in ms */
|
||||
export const API_INTERVAL = 30000;
|
||||
export const TOPOLOGY_INTERVAL = 5000;
|
||||
export const API_REFRESH_INTERVAL = 30000;
|
||||
export const TOPOLOGY_REFRESH_INTERVAL = 5000;
|
||||
export const NODES_DELTA_BUFFER_FEED_INTERVAL = 1000;
|
||||
|
||||
export const TOPOLOGY_LOADER_DELAY = 100;
|
||||
|
||||
export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10;
|
||||
export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200;
|
||||
export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500;
|
||||
export const TIMELINE_DEBOUNCE_INTERVAL = 500;
|
||||
|
||||
export const TIMELINE_TICK_INTERVAL = 100;
|
||||
export const TIMELINE_SLIDER_UPDATE_INTERVAL = 10000;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { is, fromJS } from 'immutable';
|
||||
import expect from 'expect';
|
||||
|
||||
import { TABLE_VIEW_MODE } from '../../constants/naming';
|
||||
// Root reducer test suite using Jasmine matchers
|
||||
import { constructEdgeId } from '../../utils/layouter-utils';
|
||||
|
||||
// Root reducer test suite using Jasmine matchers
|
||||
describe('RootReducer', () => {
|
||||
const ActionTypes = require('../../constants/action-types').default;
|
||||
const reducer = require('../root').default;
|
||||
@@ -512,8 +512,6 @@ describe('RootReducer', () => {
|
||||
|
||||
nextState = reducer(nextState, OpenWebsocketAction);
|
||||
expect(nextState.get('websocketClosed')).toBeFalsy();
|
||||
// opened socket clears nodes
|
||||
expect(nextState.get('nodes').toJS()).toEqual({});
|
||||
});
|
||||
|
||||
// adjacency test
|
||||
@@ -538,12 +536,19 @@ describe('RootReducer', () => {
|
||||
let nextState = initialState;
|
||||
nextState = reducer(nextState, ReceiveTopologiesAction);
|
||||
nextState = reducer(nextState, ClickTopologyAction);
|
||||
expect(isTopologyEmpty(nextState)).toBeTruthy();
|
||||
|
||||
nextState = reducer(nextState, ReceiveNodesDeltaAction);
|
||||
expect(isTopologyEmpty(nextState)).toBeFalsy();
|
||||
|
||||
nextState = reducer(nextState, ClickTopology2Action);
|
||||
nextState = reducer(nextState, ReceiveNodesDeltaAction);
|
||||
expect(isTopologyEmpty(nextState)).toBeTruthy();
|
||||
|
||||
nextState = reducer(nextState, ClickTopologyAction);
|
||||
expect(isTopologyEmpty(nextState)).toBeTruthy();
|
||||
|
||||
nextState = reducer(nextState, ReceiveNodesDeltaAction);
|
||||
expect(isTopologyEmpty(nextState)).toBeFalsy();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */
|
||||
import debug from 'debug';
|
||||
import moment from 'moment';
|
||||
import { size, each, includes, isEqual } from 'lodash';
|
||||
import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
|
||||
OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable';
|
||||
import {
|
||||
fromJS,
|
||||
is as isDeepEqual,
|
||||
List as makeList,
|
||||
Map as makeMap,
|
||||
OrderedMap as makeOrderedMap,
|
||||
Set as makeSet,
|
||||
} from 'immutable';
|
||||
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import {
|
||||
@@ -15,6 +22,7 @@ import {
|
||||
isResourceViewModeSelector,
|
||||
} from '../selectors/topology';
|
||||
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
|
||||
import { consolidateNodesDeltas } from '../utils/nodes-delta-utils';
|
||||
import { applyPinnedSearches } from '../utils/search-utils';
|
||||
import {
|
||||
findTopologyById,
|
||||
@@ -56,6 +64,7 @@ export const initialState = makeMap({
|
||||
mouseOverNodeId: null,
|
||||
nodeDetails: makeOrderedMap(), // nodeId -> details
|
||||
nodes: makeOrderedMap(), // nodeId -> node
|
||||
nodesDeltaBuffer: makeList(),
|
||||
nodesLoaded: false,
|
||||
// nodes cache, infrequently updated, used for search & resource view
|
||||
nodesByTopology: makeMap(), // topologyId -> nodes
|
||||
@@ -78,11 +87,13 @@ export const initialState = makeMap({
|
||||
topologyOptions: makeOrderedMap(), // topologyId -> options
|
||||
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
|
||||
topologyViewMode: GRAPH_VIEW_MODE,
|
||||
updatePausedAt: null, // Date
|
||||
updatePausedAt: null,
|
||||
version: '...',
|
||||
versionUpdate: null,
|
||||
viewport: makeMap(),
|
||||
websocketClosed: false,
|
||||
websocketTransitioning: false,
|
||||
websocketQueryMillisecondsInPast: 0,
|
||||
zoomCache: makeMap(),
|
||||
serviceImages: makeMap()
|
||||
});
|
||||
@@ -285,7 +296,8 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.CLICK_PAUSE_UPDATE: {
|
||||
return state.set('updatePausedAt', new Date());
|
||||
const millisecondsInPast = state.get('websocketQueryMillisecondsInPast');
|
||||
return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast));
|
||||
}
|
||||
|
||||
case ActionTypes.CLICK_RELATIVE: {
|
||||
@@ -331,7 +343,8 @@ export function rootReducer(state = initialState, action) {
|
||||
state = resumeUpdate(state);
|
||||
state = closeAllNodeDetails(state);
|
||||
|
||||
if (action.topologyId !== state.get('currentTopologyId')) {
|
||||
const currentTopologyId = state.get('currentTopologyId');
|
||||
if (action.topologyId !== currentTopologyId) {
|
||||
state = setTopology(state, action.topologyId);
|
||||
state = clearNodes(state);
|
||||
}
|
||||
@@ -339,11 +352,47 @@ export function rootReducer(state = initialState, action) {
|
||||
return state;
|
||||
}
|
||||
|
||||
//
|
||||
// websockets
|
||||
//
|
||||
|
||||
case ActionTypes.OPEN_WEBSOCKET: {
|
||||
return state.set('websocketClosed', false);
|
||||
}
|
||||
|
||||
case ActionTypes.START_WEBSOCKET_TRANSITION_LOADER: {
|
||||
return state.set('websocketTransitioning', true);
|
||||
}
|
||||
|
||||
case ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST: {
|
||||
return state.set('websocketQueryMillisecondsInPast', action.millisecondsInPast);
|
||||
}
|
||||
|
||||
case ActionTypes.CLOSE_WEBSOCKET: {
|
||||
if (!state.get('websocketClosed')) {
|
||||
state = state.set('websocketClosed', true);
|
||||
}
|
||||
return state;
|
||||
return state.set('websocketClosed', true);
|
||||
}
|
||||
|
||||
//
|
||||
// nodes delta buffer
|
||||
//
|
||||
|
||||
case ActionTypes.CLEAR_NODES_DELTA_BUFFER: {
|
||||
return state.update('nodesDeltaBuffer', buffer => buffer.clear());
|
||||
}
|
||||
|
||||
case ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER: {
|
||||
const firstDelta = state.getIn(['nodesDeltaBuffer', 0]);
|
||||
const secondDelta = state.getIn(['nodesDeltaBuffer', 1]);
|
||||
const deltaUnion = consolidateNodesDeltas(firstDelta, secondDelta);
|
||||
return state.update('nodesDeltaBuffer', buffer => buffer.shift().set(0, deltaUnion));
|
||||
}
|
||||
|
||||
case ActionTypes.POP_NODES_DELTA_BUFFER: {
|
||||
return state.update('nodesDeltaBuffer', buffer => buffer.shift());
|
||||
}
|
||||
|
||||
case ActionTypes.BUFFER_NODES_DELTA: {
|
||||
return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta));
|
||||
}
|
||||
|
||||
//
|
||||
@@ -479,13 +528,6 @@ export function rootReducer(state = initialState, action) {
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.OPEN_WEBSOCKET: {
|
||||
// flush nodes cache after re-connect
|
||||
state = state.update('nodes', nodes => nodes.clear());
|
||||
state = state.set('websocketClosed', false);
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.DO_CONTROL_ERROR: {
|
||||
return state.setIn(['controlStatus', action.nodeId], makeMap({
|
||||
pending: false,
|
||||
@@ -567,18 +609,22 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.RECEIVE_NODES_DELTA: {
|
||||
const emptyMessage = !action.delta.add && !action.delta.remove
|
||||
&& !action.delta.update;
|
||||
|
||||
if (!emptyMessage) {
|
||||
log('RECEIVE_NODES_DELTA',
|
||||
'remove', size(action.delta.remove),
|
||||
'update', size(action.delta.update),
|
||||
'add', size(action.delta.add));
|
||||
}
|
||||
log('RECEIVE_NODES_DELTA',
|
||||
'remove', size(action.delta.remove),
|
||||
'update', size(action.delta.update),
|
||||
'add', size(action.delta.add));
|
||||
|
||||
state = state.set('errorUrl', null);
|
||||
|
||||
// When moving in time, we will consider the transition complete
|
||||
// only when the first batch of nodes delta has been received. We
|
||||
// do that because we want to keep the previous state blurred instead
|
||||
// of transitioning over an empty state like when switching topologies.
|
||||
if (state.get('websocketTransitioning')) {
|
||||
state = state.set('websocketTransitioning', false);
|
||||
state = clearNodes(state);
|
||||
}
|
||||
|
||||
// nodes that no longer exist
|
||||
each(action.delta.remove, (nodeId) => {
|
||||
// in case node disappears before mouseleave event
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
client/app/scripts/selectors/time-travel.js
Normal file
16
client/app/scripts/selectors/time-travel.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
|
||||
export const isPausedSelector = createSelector(
|
||||
[
|
||||
state => state.get('updatePausedAt')
|
||||
],
|
||||
updatePausedAt => updatePausedAt !== null
|
||||
);
|
||||
|
||||
export const isWebsocketQueryingCurrentSelector = createSelector(
|
||||
[
|
||||
state => state.get('websocketQueryMillisecondsInPast')
|
||||
],
|
||||
websocketQueryMillisecondsInPast => websocketQueryMillisecondsInPast === 0
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
60
client/app/scripts/utils/nodes-delta-utils.js
Normal file
60
client/app/scripts/utils/nodes-delta-utils.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import debug from 'debug';
|
||||
import { union, size, map, find, reject, each } from 'lodash';
|
||||
|
||||
const log = debug('scope:nodes-delta-utils');
|
||||
|
||||
|
||||
// TODO: It would be nice to have a unit test for this function.
|
||||
export function consolidateNodesDeltas(first, second) {
|
||||
let toAdd = union(first.add, second.add);
|
||||
let toUpdate = union(first.update, second.update);
|
||||
let toRemove = union(first.remove, second.remove);
|
||||
log('Consolidating delta buffer',
|
||||
'add', size(toAdd),
|
||||
'update', size(toUpdate),
|
||||
'remove', size(toRemove));
|
||||
|
||||
// check if an added node in first was updated in second -> add second update
|
||||
toAdd = map(toAdd, (node) => {
|
||||
const updateNode = find(second.update, {id: node.id});
|
||||
if (updateNode) {
|
||||
toUpdate = reject(toUpdate, {id: node.id});
|
||||
return updateNode;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// check if an updated node in first was updated in second -> updated second update
|
||||
// no action needed, successive updates are fine
|
||||
|
||||
// check if an added node in first was removed in second -> dont add, dont remove
|
||||
each(first.add, (node) => {
|
||||
const removedNode = find(second.remove, {id: node.id});
|
||||
if (removedNode) {
|
||||
toAdd = reject(toAdd, {id: node.id});
|
||||
toRemove = reject(toRemove, {id: node.id});
|
||||
}
|
||||
});
|
||||
|
||||
// check if an updated node in first was removed in second -> remove
|
||||
each(first.update, (node) => {
|
||||
const removedNode = find(second.remove, {id: node.id});
|
||||
if (removedNode) {
|
||||
toUpdate = reject(toUpdate, {id: node.id});
|
||||
}
|
||||
});
|
||||
|
||||
// check if an removed node in first was added in second -> update
|
||||
// remove -> add is fine for the store
|
||||
|
||||
log('Consolidated delta buffer',
|
||||
'add', size(toAdd),
|
||||
'update', size(toUpdate),
|
||||
'remove', size(toRemove));
|
||||
|
||||
return {
|
||||
add: toAdd.length > 0 ? toAdd : null,
|
||||
update: toUpdate.length > 0 ? toUpdate : null,
|
||||
remove: toRemove.length > 0 ? toRemove : null
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { endsWith } from 'lodash';
|
||||
import { Set as makeSet, List as makeList } from 'immutable';
|
||||
|
||||
import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
|
||||
import { isResourceViewModeSelector } from '../selectors/topology';
|
||||
import { pinnedMetricSelector } from '../selectors/node-metric';
|
||||
|
||||
@@ -133,15 +134,23 @@ export function getCurrentTopologyOptions(state) {
|
||||
return state.getIn(['currentTopology', 'options']);
|
||||
}
|
||||
|
||||
export function isTopologyEmpty(state) {
|
||||
// Consider a topology in the resource view empty if it has no pinned metric.
|
||||
const resourceViewEmpty = isResourceViewModeSelector(state) && !pinnedMetricSelector(state);
|
||||
// Otherwise (in graph and table view), we only look at the node count.
|
||||
export function isTopologyNodeCountZero(state) {
|
||||
const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0);
|
||||
const nodesEmpty = nodeCount === 0 && state.get('nodes').size === 0;
|
||||
return resourceViewEmpty || nodesEmpty;
|
||||
return nodeCount === 0 && isWebsocketQueryingCurrentSelector(state);
|
||||
}
|
||||
|
||||
export function isNodesDisplayEmpty(state) {
|
||||
// Consider a topology in the resource view empty if it has no pinned metric.
|
||||
if (isResourceViewModeSelector(state)) {
|
||||
return !pinnedMetricSelector(state);
|
||||
}
|
||||
// Otherwise (in graph and table view), we only look at the nodes content.
|
||||
return state.get('nodes').isEmpty();
|
||||
}
|
||||
|
||||
export function isTopologyEmpty(state) {
|
||||
return isTopologyNodeCountZero(state) || isNodesDisplayEmpty(state);
|
||||
}
|
||||
|
||||
export function getAdjacentNodes(state, originNodeId) {
|
||||
let adjacentNodes = makeSet();
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import Immutable from 'immutable';
|
||||
import { union, size, map, find, reject, each } from 'lodash';
|
||||
|
||||
import { receiveNodesDelta } from '../actions/app-actions';
|
||||
|
||||
const log = debug('scope:update-buffer-utils');
|
||||
const makeList = Immutable.List;
|
||||
const feedInterval = 1000;
|
||||
const bufferLength = 100;
|
||||
|
||||
let deltaBuffer = makeList();
|
||||
let updateTimer = null;
|
||||
|
||||
function isPaused(getState) {
|
||||
return getState().get('updatePausedAt') !== null;
|
||||
}
|
||||
|
||||
export function resetUpdateBuffer() {
|
||||
clearTimeout(updateTimer);
|
||||
deltaBuffer = deltaBuffer.clear();
|
||||
}
|
||||
|
||||
function maybeUpdate(getState) {
|
||||
if (isPaused(getState)) {
|
||||
clearTimeout(updateTimer);
|
||||
resetUpdateBuffer();
|
||||
} else {
|
||||
if (deltaBuffer.size > 0) {
|
||||
const delta = deltaBuffer.first();
|
||||
deltaBuffer = deltaBuffer.shift();
|
||||
receiveNodesDelta(delta);
|
||||
}
|
||||
if (deltaBuffer.size > 0) {
|
||||
updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// consolidate first buffer entry with second
|
||||
function consolidateBuffer() {
|
||||
const first = deltaBuffer.first();
|
||||
deltaBuffer = deltaBuffer.shift();
|
||||
const second = deltaBuffer.first();
|
||||
let toAdd = union(first.add, second.add);
|
||||
let toUpdate = union(first.update, second.update);
|
||||
let toRemove = union(first.remove, second.remove);
|
||||
log('Consolidating delta buffer', 'add', size(toAdd), 'update',
|
||||
size(toUpdate), 'remove', size(toRemove));
|
||||
|
||||
// check if an added node in first was updated in second -> add second update
|
||||
toAdd = map(toAdd, (node) => {
|
||||
const updateNode = find(second.update, {id: node.id});
|
||||
if (updateNode) {
|
||||
toUpdate = reject(toUpdate, {id: node.id});
|
||||
return updateNode;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// check if an updated node in first was updated in second -> updated second update
|
||||
// no action needed, successive updates are fine
|
||||
|
||||
// check if an added node in first was removed in second -> dont add, dont remove
|
||||
each(first.add, (node) => {
|
||||
const removedNode = find(second.remove, {id: node.id});
|
||||
if (removedNode) {
|
||||
toAdd = reject(toAdd, {id: node.id});
|
||||
toRemove = reject(toRemove, {id: node.id});
|
||||
}
|
||||
});
|
||||
|
||||
// check if an updated node in first was removed in second -> remove
|
||||
each(first.update, (node) => {
|
||||
const removedNode = find(second.remove, {id: node.id});
|
||||
if (removedNode) {
|
||||
toUpdate = reject(toUpdate, {id: node.id});
|
||||
}
|
||||
});
|
||||
|
||||
// check if an removed node in first was added in second -> update
|
||||
// remove -> add is fine for the store
|
||||
|
||||
// update buffer
|
||||
log('Consolidated delta buffer', 'add', size(toAdd), 'update',
|
||||
size(toUpdate), 'remove', size(toRemove));
|
||||
deltaBuffer.set(0, {
|
||||
add: toAdd.length > 0 ? toAdd : null,
|
||||
update: toUpdate.length > 0 ? toUpdate : null,
|
||||
remove: toRemove.length > 0 ? toRemove : null
|
||||
});
|
||||
}
|
||||
|
||||
export function bufferDeltaUpdate(delta) {
|
||||
if (delta.add === null && delta.update === null && delta.remove === null) {
|
||||
log('Discarding empty nodes delta');
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaBuffer.size >= bufferLength) {
|
||||
consolidateBuffer();
|
||||
}
|
||||
|
||||
deltaBuffer = deltaBuffer.push(delta);
|
||||
log('Buffering node delta, new size', deltaBuffer.size);
|
||||
}
|
||||
|
||||
export function getUpdateBufferSize() {
|
||||
return deltaBuffer.size;
|
||||
}
|
||||
|
||||
export function resumeUpdate(getState) {
|
||||
maybeUpdate(getState);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import debug from 'debug';
|
||||
import moment from 'moment';
|
||||
import reqwest from 'reqwest';
|
||||
import defaults from 'lodash/defaults';
|
||||
import { Map as makeMap, List } from 'immutable';
|
||||
import { defaults } from 'lodash';
|
||||
import { fromJS, Map as makeMap, List } from 'immutable';
|
||||
|
||||
import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError,
|
||||
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
|
||||
@@ -9,8 +10,11 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr
|
||||
receiveControlSuccess, receiveTopologies, receiveNotFound,
|
||||
receiveNodesForTopology } from '../actions/app-actions';
|
||||
|
||||
import { getCurrentTopologyUrl } from '../utils/topology-utils';
|
||||
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
|
||||
import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
|
||||
import { activeTopologyOptionsSelector } from '../selectors/topology';
|
||||
import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
|
||||
import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer';
|
||||
|
||||
const log = debug('scope:web-api-utils');
|
||||
|
||||
@@ -34,25 +38,25 @@ const csrfToken = (() => {
|
||||
|
||||
let socket;
|
||||
let reconnectTimer = 0;
|
||||
let currentUrl = null;
|
||||
let currentOptions = null;
|
||||
let topologyTimer = 0;
|
||||
let apiDetailsTimer = 0;
|
||||
let controlErrorTimer = 0;
|
||||
let createWebsocketAt = 0;
|
||||
let firstMessageOnWebsocketAt = 0;
|
||||
let currentUrl = null;
|
||||
let createWebsocketAt = null;
|
||||
let firstMessageOnWebsocketAt = null;
|
||||
let continuePolling = true;
|
||||
|
||||
export function buildOptionsQuery(options) {
|
||||
if (options) {
|
||||
return options.map((value, param) => {
|
||||
if (List.isList(value)) {
|
||||
value = value.join(',');
|
||||
}
|
||||
return `${param}=${value}`;
|
||||
}).join('&');
|
||||
}
|
||||
return '';
|
||||
export function buildUrlQuery(params) {
|
||||
if (!params) return '';
|
||||
|
||||
// Ignore the entries with values `null` or `undefined`.
|
||||
return params.map((value, param) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (List.isList(value)) {
|
||||
value = value.join(',');
|
||||
}
|
||||
return `${param}=${value}`;
|
||||
}).filter(s => s).join('&');
|
||||
}
|
||||
|
||||
export function basePath(urlPath) {
|
||||
@@ -93,7 +97,16 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l
|
||||
return `${wsProto}://${host}${process.env.SCOPE_API_PREFIX || ''}${basePath(pathname)}`;
|
||||
}
|
||||
|
||||
function createWebsocket(topologyUrl, optionsQuery, dispatch) {
|
||||
function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) {
|
||||
const query = buildUrlQuery(fromJS({
|
||||
t: updateFrequency,
|
||||
timestamp: queryTimestamp,
|
||||
...topologyOptions.toJS(),
|
||||
}));
|
||||
return `${getWebsocketUrl()}${topologyUrl}/ws?${query}`;
|
||||
}
|
||||
|
||||
function createWebsocket(websocketUrl, dispatch) {
|
||||
if (socket) {
|
||||
socket.onclose = null;
|
||||
socket.onerror = null;
|
||||
@@ -104,30 +117,31 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) {
|
||||
|
||||
// profiling
|
||||
createWebsocketAt = new Date();
|
||||
firstMessageOnWebsocketAt = 0;
|
||||
firstMessageOnWebsocketAt = null;
|
||||
|
||||
socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`);
|
||||
socket = new WebSocket(websocketUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
log(`Opening websocket to ${websocketUrl}`);
|
||||
dispatch(openWebsocket());
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
log(`Closing websocket to ${topologyUrl}`, socket.readyState);
|
||||
log(`Closing websocket to ${websocketUrl}`, socket.readyState);
|
||||
socket = null;
|
||||
dispatch(closeWebsocket());
|
||||
|
||||
if (continuePolling) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
createWebsocket(topologyUrl, optionsQuery, dispatch);
|
||||
createWebsocket(websocketUrl, dispatch);
|
||||
}, reconnectTimerInterval);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
log(`Error in websocket to ${topologyUrl}`);
|
||||
dispatch(receiveError(currentUrl));
|
||||
log(`Error in websocket to ${websocketUrl}`);
|
||||
dispatch(receiveError(websocketUrl));
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
@@ -170,7 +184,7 @@ function getNodesForTopologies(getState, dispatch, topologyIds, topologyOptions
|
||||
getState().get('topologyUrlsById')
|
||||
.filter((_, topologyId) => topologyIds.contains(topologyId))
|
||||
.reduce((sequence, topologyUrl, topologyId) => sequence.then(() => {
|
||||
const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId));
|
||||
const optionsQuery = buildUrlQuery(topologyOptions.get(topologyId));
|
||||
return doRequest({ url: `${getApiPath()}${topologyUrl}?${optionsQuery}` });
|
||||
})
|
||||
.then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))),
|
||||
@@ -200,7 +214,7 @@ export function getTopologies(options, dispatch, initialPoll) {
|
||||
// Used to resume polling when navigating between pages in Weave Cloud.
|
||||
continuePolling = initialPoll === true ? true : continuePolling;
|
||||
clearTimeout(topologyTimer);
|
||||
const optionsQuery = buildOptionsQuery(options);
|
||||
const optionsQuery = buildUrlQuery(options);
|
||||
const url = `${getApiPath()}/api/topology?${optionsQuery}`;
|
||||
doRequest({
|
||||
url,
|
||||
@@ -209,7 +223,7 @@ export function getTopologies(options, dispatch, initialPoll) {
|
||||
dispatch(receiveTopologies(res));
|
||||
topologyTimer = setTimeout(() => {
|
||||
getTopologies(options, dispatch);
|
||||
}, TOPOLOGY_INTERVAL);
|
||||
}, TOPOLOGY_REFRESH_INTERVAL);
|
||||
}
|
||||
},
|
||||
error: (req) => {
|
||||
@@ -219,26 +233,32 @@ export function getTopologies(options, dispatch, initialPoll) {
|
||||
if (continuePolling) {
|
||||
topologyTimer = setTimeout(() => {
|
||||
getTopologies(options, dispatch);
|
||||
}, TOPOLOGY_INTERVAL);
|
||||
}, TOPOLOGY_REFRESH_INTERVAL);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: topologyUrl and options are always used for the current topology so they as arguments
|
||||
// can be replaced by the `state` and then retrieved here internally from selectors.
|
||||
export function getNodesDelta(topologyUrl, options, dispatch) {
|
||||
const optionsQuery = buildOptionsQuery(options);
|
||||
function getWebsocketQueryTimestamp(state) {
|
||||
// The timestamp query parameter will be used only if it's in the past.
|
||||
if (isWebsocketQueryingCurrentSelector(state)) return null;
|
||||
|
||||
const millisecondsInPast = state.get('websocketQueryMillisecondsInPast');
|
||||
return moment().utc().subtract(millisecondsInPast).toISOString();
|
||||
}
|
||||
|
||||
export function updateWebsocketChannel(state, dispatch) {
|
||||
const topologyUrl = getCurrentTopologyUrl(state);
|
||||
const topologyOptions = activeTopologyOptionsSelector(state);
|
||||
const queryTimestamp = getWebsocketQueryTimestamp(state);
|
||||
const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp);
|
||||
// Only recreate websocket if url changed or if forced (weave cloud instance reload);
|
||||
// Check for truthy options and that options have changed.
|
||||
const isNewOptions = currentOptions && currentOptions !== optionsQuery;
|
||||
const isNewUrl = topologyUrl !== currentUrl || isNewOptions;
|
||||
const isNewUrl = websocketUrl !== currentUrl;
|
||||
// `topologyUrl` can be undefined initially, so only create a socket if it is truthy
|
||||
// and no socket exists, or if we get a new url.
|
||||
if ((topologyUrl && !socket) || (topologyUrl && isNewUrl)) {
|
||||
createWebsocket(topologyUrl, optionsQuery, dispatch);
|
||||
currentUrl = topologyUrl;
|
||||
currentOptions = optionsQuery;
|
||||
if (topologyUrl && (!socket || isNewUrl)) {
|
||||
createWebsocket(websocketUrl, dispatch);
|
||||
currentUrl = websocketUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +270,7 @@ export function getNodeDetails(topologyUrlsById, currentTopologyId, options, nod
|
||||
let urlComponents = [getApiPath(), topologyUrl, '/', encodeURIComponent(obj.id)];
|
||||
if (currentTopologyId === obj.topologyId) {
|
||||
// Only forward filters for nodes in the current topology
|
||||
const optionsQuery = buildOptionsQuery(options);
|
||||
const optionsQuery = buildUrlQuery(options);
|
||||
urlComponents = urlComponents.concat(['?', optionsQuery]);
|
||||
}
|
||||
const url = urlComponents.join('');
|
||||
@@ -288,7 +308,7 @@ export function getApiDetails(dispatch) {
|
||||
if (continuePolling) {
|
||||
apiDetailsTimer = setTimeout(() => {
|
||||
getApiDetails(dispatch);
|
||||
}, API_INTERVAL);
|
||||
}, API_REFRESH_INTERVAL);
|
||||
}
|
||||
},
|
||||
error: (req) => {
|
||||
@@ -297,7 +317,7 @@ export function getApiDetails(dispatch) {
|
||||
if (continuePolling) {
|
||||
apiDetailsTimer = setTimeout(() => {
|
||||
getApiDetails(dispatch);
|
||||
}, API_INTERVAL / 2);
|
||||
}, API_REFRESH_INTERVAL / 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -407,6 +427,6 @@ export function teardownWebsockets() {
|
||||
socket.onopen = null;
|
||||
socket.close();
|
||||
socket = null;
|
||||
currentOptions = null;
|
||||
currentUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
transition: opacity .5s $base-ease;
|
||||
}
|
||||
|
||||
.blinkable {
|
||||
animation: blinking 1.5s infinite $base-ease;
|
||||
}
|
||||
|
||||
.hang-around {
|
||||
transition-delay: .5s;
|
||||
}
|
||||
@@ -48,13 +52,40 @@
|
||||
}
|
||||
|
||||
.overlay-wrapper {
|
||||
align-items: center;
|
||||
background-color: fade-out($background-average-color, 0.1);
|
||||
border-radius: 4px;
|
||||
color: $text-tertiary-color;
|
||||
display: flex;
|
||||
font-size: 0.7rem;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
bottom: 11px;
|
||||
|
||||
a {
|
||||
@extend .btn-opacity;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: $text-secondary-color;
|
||||
cursor: pointer;
|
||||
padding: 1px 3px;
|
||||
|
||||
.fa {
|
||||
font-size: 150%;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
&:hover, &.selected {
|
||||
border: 1px solid $text-tertiary-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
& > * { @extend .blinkable; }
|
||||
border: 1px solid $text-tertiary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-opacity {
|
||||
@@ -137,17 +168,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.rc-slider {
|
||||
.rc-slider-step { cursor: pointer; }
|
||||
.rc-slider-track { background-color: $text-tertiary-color; }
|
||||
.rc-slider-rail { background-color: $border-light-color; }
|
||||
.rc-slider-handle { border-color: $text-tertiary-color; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
@extend .overlay-wrapper;
|
||||
bottom: 11px;
|
||||
right: 43px;
|
||||
|
||||
a {
|
||||
@extend .btn-opacity;
|
||||
color: $text-secondary-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-status {
|
||||
margin-right: 1em;
|
||||
}
|
||||
@@ -162,33 +194,12 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&-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;
|
||||
}
|
||||
&-tools {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-icon &-label {
|
||||
margin-right: 0.5em;
|
||||
&-icon {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@@ -197,6 +208,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nodes-wrapper {
|
||||
@extend .hideable;
|
||||
|
||||
&.blurred { opacity: 0.2; }
|
||||
}
|
||||
|
||||
.time-travel {
|
||||
@extend .overlay-wrapper;
|
||||
display: block;
|
||||
right: 530px;
|
||||
|
||||
&-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.time-travel-jump-loader {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.time-travel-timestamp-info, .pause-text {
|
||||
font-size: 115%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.button { margin-left: 0.5em; }
|
||||
|
||||
.time-travel-timestamp:not(.current) {
|
||||
& > * { @extend .blinkable; }
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&-slider {
|
||||
width: 355px;
|
||||
|
||||
.slider-tip {
|
||||
display: inline-block;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
padding: 2px 0 10px;
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
padding: 0 7px;
|
||||
|
||||
a { padding: 0 3px; }
|
||||
}
|
||||
}
|
||||
|
||||
.rc-slider {
|
||||
margin: 0 10px 8px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.topologies {
|
||||
margin: 8px 4px;
|
||||
display: flex;
|
||||
@@ -279,10 +355,12 @@
|
||||
opacity: 0.25;
|
||||
font-size: 320px;
|
||||
}
|
||||
|
||||
li { padding-top: 5px; }
|
||||
}
|
||||
|
||||
&-loading &-error-icon-container {
|
||||
animation: blinking 2.0s infinite $base-ease;
|
||||
@extend .blinkable;
|
||||
}
|
||||
|
||||
&-loading {
|
||||
@@ -695,8 +773,8 @@
|
||||
color: $white;
|
||||
|
||||
&-icon {
|
||||
@extend .blinkable;
|
||||
margin-right: 0.5em;
|
||||
animation: blinking 2.0s infinite $base-ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1409,9 +1487,9 @@
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
padding: 4px;
|
||||
bottom: 11px;
|
||||
left: 11px;
|
||||
padding: 5px;
|
||||
font-size: .7rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
@@ -1760,27 +1838,16 @@
|
||||
|
||||
.zoom-control {
|
||||
@extend .overlay-wrapper;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 10px 10px 5px;
|
||||
padding: 5px 7px 0;
|
||||
bottom: 50px;
|
||||
right: 40px;
|
||||
|
||||
.zoom-in, .zoom-out {
|
||||
@extend .btn-opacity;
|
||||
color: $text-secondary-color;
|
||||
cursor: pointer;
|
||||
font-size: 150%;
|
||||
}
|
||||
a:hover { border-color: transparent; }
|
||||
|
||||
.rc-slider {
|
||||
margin: 10px 0;
|
||||
margin: 5px 0;
|
||||
height: 60px;
|
||||
|
||||
.rc-slider-step { cursor: pointer; }
|
||||
.rc-slider-track { background-color: $text-tertiary-color; }
|
||||
.rc-slider-rail { background-color: $border-light-color; }
|
||||
.rc-slider-handle { border-color: $text-tertiary-color; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user