Files
weave-scope/client/app/scripts/reducers/root.js
Roland Schilter 21a0093c08 Keep topology nav visible if selected
If the scope-app API unexpectedly restarts, it has no report
at hand (until it gets one from the probe) and sends node
count 0 to the frontend for all topologies. Once the report
arrives, it will send the proper count.

What happened was the frontend did hide Processes for a short
time till the node count recovered. This moved the topology
selection to the always visible Containers (hide_if_empty == false)
while keeping the graph as is.

Once the node count recovers, Processes comes back but the
selection is still at Containers.

We now keep the selected topology visible at all time even if
the API returns a node count of 0. This recovers nicely when
the correct node counts come in. Once the user selects a different
topology while and a backend response arrives, it disappears.

Fixes #2646
2017-07-12 17:12:43 +02:00

762 lines
24 KiB
JavaScript

/* 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,
} from 'immutable';
import ActionTypes from '../constants/action-types';
import {
GRAPH_VIEW_MODE,
TABLE_VIEW_MODE,
} from '../constants/naming';
import {
graphExceedsComplexityThreshSelector,
isResourceViewModeSelector,
} from '../selectors/topology';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
import { applyPinnedSearches } from '../utils/search-utils';
import {
findTopologyById,
setTopologyUrlsById,
updateTopologyIds,
filterHiddenTopologies,
addTopologyFullname,
getDefaultTopology,
} from '../utils/topology-utils';
const log = debug('scope:app-store');
const error = debug('scope:error');
// Helpers
const topologySorter = topology => topology.get('rank');
// Initial values
export const initialState = makeMap({
contrastMode: false,
controlPipes: makeOrderedMap(), // pipeId -> controlPipe
controlStatus: makeMap(),
currentTopology: null,
currentTopologyId: null,
errorUrl: null,
exportingGraph: false,
forceRelayout: false,
gridSortedBy: null,
gridSortedDesc: null,
hostname: '...',
hoveredMetricType: null,
initialNodesLoaded: false,
mouseOverEdgeId: null,
mouseOverNodeId: null,
nodeDetails: makeOrderedMap(), // nodeId -> details
nodes: makeOrderedMap(), // nodeId -> node
nodesLoaded: false,
// nodes cache, infrequently updated, used for search & resource view
nodesByTopology: makeMap(), // topologyId -> nodes
// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'.
// allows us to keep the same metric "type" selected when the topology changes.
pausedAt: null,
pinnedMetricType: null,
pinnedNetwork: null,
plugins: makeList(),
pinnedSearches: makeList(), // list of node filters
routeSet: false,
searchFocused: false,
searchQuery: null,
selectedNetwork: null,
selectedNodeId: null,
showingHelp: false,
showingTimeTravel: false,
showingTroubleshootingMenu: false,
showingNetworks: false,
timeTravelTransitioning: false,
topologies: makeList(),
topologiesLoaded: false,
topologyOptions: makeOrderedMap(), // topologyId -> options
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
topologyViewMode: GRAPH_VIEW_MODE,
version: '...',
versionUpdate: null,
viewport: makeMap(),
websocketClosed: false,
zoomCache: makeMap(),
serviceImages: makeMap()
});
function calcSelectType(topology) {
const result = {
...topology,
options: topology.options && topology.options.map((option) => {
// Server doesn't return the `selectType` key unless the option is something other than `one`.
// Default to `one` if undefined, so the component doesn't have to handle this.
option.selectType = option.selectType || 'one';
return option;
})
};
if (topology.sub_topologies) {
result.sub_topologies = topology.sub_topologies.map(calcSelectType);
}
return result;
}
// adds ID field to topology (based on last part of URL path) and save urls in
// map for easy lookup
function processTopologies(state, nextTopologies) {
// add IDs to topology objects in-place
const topologiesWithId = updateTopologyIds(nextTopologies);
// filter out hidden topos
const visibleTopologies = filterHiddenTopologies(topologiesWithId, state.get('currentTopologyId'));
// set `selectType` field for topology and sub_topologies options (recursive).
const topologiesWithSelectType = visibleTopologies.map(calcSelectType);
// cache URLs by ID
state = state.set('topologyUrlsById',
setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithSelectType));
const topologiesWithFullnames = addTopologyFullname(topologiesWithSelectType);
const immNextTopologies = fromJS(topologiesWithFullnames).sortBy(topologySorter);
return state.set('topologies', immNextTopologies);
}
function setTopology(state, topologyId) {
state = state.set('currentTopology', findTopologyById(state.get('topologies'), topologyId));
return state.set('currentTopologyId', topologyId);
}
function setDefaultTopologyOptions(state, topologyList) {
topologyList.forEach((topology) => {
let defaultOptions = makeOrderedMap();
if (topology.has('options') && topology.get('options')) {
topology.get('options').forEach((option) => {
const optionId = option.get('id');
const defaultValue = option.get('defaultValue');
defaultOptions = defaultOptions.set(optionId, [defaultValue]);
});
}
if (defaultOptions.size) {
state = state.setIn(['topologyOptions', topology.get('id')],
defaultOptions
);
}
});
return state;
}
function closeNodeDetails(state, nodeId) {
const nodeDetails = state.get('nodeDetails');
if (nodeDetails.size > 0) {
const popNodeId = nodeId || nodeDetails.keySeq().last();
// remove pipe if it belongs to the node being closed
state = state.update('controlPipes',
controlPipes => controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId));
state = state.deleteIn(['nodeDetails', popNodeId]);
}
if (state.get('nodeDetails').size === 0 || state.get('selectedNodeId') === nodeId) {
state = state.set('selectedNodeId', null);
}
return state;
}
function closeAllNodeDetails(state) {
while (state.get('nodeDetails').size) {
state = closeNodeDetails(state);
}
return state;
}
function clearNodes(state) {
return state
.update('nodes', nodes => nodes.clear())
.set('nodesLoaded', false);
}
// TODO: These state changes should probably be calculated from selectors.
function updateStateFromNodes(state) {
// Apply pinned searches, filters nodes that dont match.
state = applyPinnedSearches(state);
// In case node or edge disappears before mouseleave event.
const nodesIds = state.get('nodes').keySeq();
if (!nodesIds.contains(state.get('mouseOverNodeId'))) {
state = state.set('mouseOverNodeId', null);
}
if (!nodesIds.some(nodeId => includes(state.get('mouseOverEdgeId'), nodeId))) {
state = state.set('mouseOverEdgeId', null);
}
// Update the nodes cache only if we're not in the resource view mode, as we
// intentionally want to keep it static before we figure how to keep it up-to-date.
if (!isResourceViewModeSelector(state)) {
const nodesForCurrentTopologyKey = ['nodesByTopology', state.get('currentTopologyId')];
state = state.setIn(nodesForCurrentTopologyKey, state.get('nodes'));
}
// Clear the error.
state = state.set('errorUrl', null);
return state;
}
export function rootReducer(state = initialState, action) {
if (!action.type) {
error('Payload missing a type!', action);
}
switch (action.type) {
case ActionTypes.BLUR_SEARCH: {
return state.set('searchFocused', false);
}
case ActionTypes.CHANGE_TOPOLOGY_OPTION: {
// set option on parent topology
const topology = findTopologyById(state.get('topologies'), action.topologyId);
if (topology) {
const topologyId = topology.get('parentId') || topology.get('id');
const optionKey = ['topologyOptions', topologyId, action.option];
const currentOption = state.getIn(['topologyOptions', topologyId, action.option]);
if (!isEqual(currentOption, action.value)) {
state = clearNodes(state);
}
state = state.setIn(optionKey, action.value);
}
return state;
}
case ActionTypes.SET_VIEWPORT_DIMENSIONS: {
return state.mergeIn(['viewport'], {
width: action.width,
height: action.height,
});
}
case ActionTypes.SET_EXPORTING_GRAPH: {
return state.set('exportingGraph', action.exporting);
}
case ActionTypes.SORT_ORDER_CHANGED: {
return state.merge({
gridSortedBy: action.sortedBy,
gridSortedDesc: action.sortedDesc,
});
}
case ActionTypes.SET_VIEW_MODE: {
return state.set('topologyViewMode', action.viewMode);
}
case ActionTypes.CACHE_ZOOM_STATE: {
return state.setIn(activeTopologyZoomCacheKeyPathSelector(state), action.zoomState);
}
case ActionTypes.CLEAR_CONTROL_ERROR: {
return state.removeIn(['controlStatus', action.nodeId, 'error']);
}
case ActionTypes.CLICK_BACKGROUND: {
if (state.get('showingHelp')) {
state = state.set('showingHelp', false);
}
if (state.get('showingTroubleshootingMenu')) {
state = state.set('showingTroubleshootingMenu', false);
}
return closeAllNodeDetails(state);
}
case ActionTypes.CLICK_CLOSE_DETAILS: {
return closeNodeDetails(state, action.nodeId);
}
case ActionTypes.CLICK_CLOSE_TERMINAL: {
return state.update('controlPipes', controlPipes => controlPipes.clear());
}
case ActionTypes.CLICK_FORCE_RELAYOUT: {
return state.set('forceRelayout', action.forceRelayout);
}
case ActionTypes.CLICK_NODE: {
const prevSelectedNodeId = state.get('selectedNodeId');
const prevDetailsStackSize = state.get('nodeDetails').size;
// click on sibling closes all
state = closeAllNodeDetails(state);
// select new node if it's not the same (in that case just delesect)
if (prevDetailsStackSize > 1 || prevSelectedNodeId !== action.nodeId) {
// dont set origin if a node was already selected, suppresses animation
const origin = prevSelectedNodeId === null ? action.origin : null;
state = state.setIn(['nodeDetails', action.nodeId],
{
id: action.nodeId,
label: action.label,
origin,
topologyId: state.get('currentTopologyId')
}
);
state = state.set('selectedNodeId', action.nodeId);
}
return state;
}
case ActionTypes.CLICK_RELATIVE: {
if (state.hasIn(['nodeDetails', action.nodeId])) {
// bring to front
const details = state.getIn(['nodeDetails', action.nodeId]);
state = state.deleteIn(['nodeDetails', action.nodeId]);
state = state.setIn(['nodeDetails', action.nodeId], details);
} else {
state = state.setIn(['nodeDetails', action.nodeId],
{
id: action.nodeId,
label: action.label,
origin: action.origin,
topologyId: action.topologyId
}
);
}
return state;
}
case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: {
state = state.update('nodeDetails',
nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId));
state = state.update('controlPipes', controlPipes => controlPipes.clear());
state = state.set('selectedNodeId', action.nodeId);
if (action.topologyId !== state.get('currentTopologyId')) {
state = setTopology(state, action.topologyId);
state = clearNodes(state);
}
return state;
}
case ActionTypes.CLICK_TOPOLOGY: {
state = closeAllNodeDetails(state);
const currentTopologyId = state.get('currentTopologyId');
if (action.topologyId !== currentTopologyId) {
state = setTopology(state, action.topologyId);
state = clearNodes(state);
}
return state;
}
//
// time control
//
case ActionTypes.RESUME_TIME: {
state = state.set('timeTravelTransitioning', true);
state = state.set('showingTimeTravel', false);
return state.set('pausedAt', null);
}
case ActionTypes.PAUSE_TIME_AT_NOW: {
state = state.set('showingTimeTravel', false);
return state.set('pausedAt', moment().utc());
}
case ActionTypes.START_TIME_TRAVEL: {
state = state.set('timeTravelTransitioning', false);
state = state.set('showingTimeTravel', true);
return state.set('pausedAt', moment().utc());
}
case ActionTypes.JUMP_TO_TIME: {
return state.set('pausedAt', action.timestamp);
}
case ActionTypes.TIME_TRAVEL_START_TRANSITION: {
return state.set('timeTravelTransitioning', true);
}
case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: {
state = state.set('timeTravelTransitioning', false);
return clearNodes(state);
}
//
// websockets
//
case ActionTypes.OPEN_WEBSOCKET: {
return state.set('websocketClosed', false);
}
case ActionTypes.CLOSE_WEBSOCKET: {
return state.set('websocketClosed', true);
}
//
// networks
//
case ActionTypes.SHOW_NETWORKS: {
if (!action.visible) {
state = state.set('selectedNetwork', null);
state = state.set('pinnedNetwork', null);
}
return state.set('showingNetworks', action.visible);
}
case ActionTypes.SELECT_NETWORK: {
return state.set('selectedNetwork', action.networkId);
}
case ActionTypes.PIN_NETWORK: {
return state.merge({
pinnedNetwork: action.networkId,
selectedNetwork: action.networkId
});
}
case ActionTypes.UNPIN_NETWORK: {
return state.merge({
pinnedNetwork: null,
});
}
//
// metrics
//
case ActionTypes.HOVER_METRIC: {
return state.set('hoveredMetricType', action.metricType);
}
case ActionTypes.UNHOVER_METRIC: {
return state.set('hoveredMetricType', null);
}
case ActionTypes.PIN_METRIC: {
return state.set('pinnedMetricType', action.metricType);
}
case ActionTypes.UNPIN_METRIC: {
return state.set('pinnedMetricType', null);
}
case ActionTypes.SHOW_HELP: {
return state.set('showingHelp', true);
}
case ActionTypes.HIDE_HELP: {
return state.set('showingHelp', false);
}
case ActionTypes.DESELECT_NODE: {
return closeNodeDetails(state);
}
case ActionTypes.DO_CONTROL: {
return state.setIn(['controlStatus', action.nodeId], makeMap({
pending: true,
error: null
}));
}
case ActionTypes.DO_SEARCH: {
return state.set('searchQuery', action.searchQuery);
}
case ActionTypes.ENTER_EDGE: {
return state.set('mouseOverEdgeId', action.edgeId);
}
case ActionTypes.ENTER_NODE: {
return state.set('mouseOverNodeId', action.nodeId);
}
case ActionTypes.LEAVE_EDGE: {
return state.set('mouseOverEdgeId', null);
}
case ActionTypes.LEAVE_NODE: {
return state.set('mouseOverNodeId', null);
}
case ActionTypes.DO_CONTROL_ERROR: {
return state.setIn(['controlStatus', action.nodeId], makeMap({
pending: false,
error: action.error
}));
}
case ActionTypes.DO_CONTROL_SUCCESS: {
return state.setIn(['controlStatus', action.nodeId], makeMap({
pending: false,
error: null
}));
}
case ActionTypes.FOCUS_SEARCH: {
return state.set('searchFocused', true);
}
case ActionTypes.PIN_SEARCH: {
const pinnedSearches = state.get('pinnedSearches');
state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query);
state = state.set('searchQuery', '');
return applyPinnedSearches(state);
}
case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: {
return closeNodeDetails(state, action.nodeId);
}
case ActionTypes.RECEIVE_CONTROL_PIPE: {
return state.setIn(['controlPipes', action.pipeId], makeOrderedMap({
id: action.pipeId,
nodeId: action.nodeId,
raw: action.rawTty,
resizeTtyControl: action.resizeTtyControl,
control: action.control
}));
}
case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: {
if (state.hasIn(['controlPipes', action.pipeId])) {
state = state.setIn(['controlPipes', action.pipeId, 'status'], action.status);
}
return state;
}
case ActionTypes.RECEIVE_ERROR: {
if (state.get('errorUrl') !== null) {
state = state.set('errorUrl', action.errorUrl);
}
return state;
}
case ActionTypes.RECEIVE_NODE_DETAILS: {
// Freeze node details data updates after the first load when paused.
if (state.getIn(['nodeDetails', action.details.id, 'details']) && state.get('pausedAt')) {
return state;
}
state = state.set('errorUrl', null);
// disregard if node is not selected anymore
if (state.hasIn(['nodeDetails', action.details.id])) {
state = state.updateIn(['nodeDetails', action.details.id], (obj) => {
const result = Object.assign({}, obj);
result.notFound = false;
result.details = action.details;
return result;
});
}
return state;
}
case ActionTypes.SET_RECEIVED_NODES_DELTA: {
// Turn on the table view if the graph is too complex, but skip
// this block if the user has already loaded topologies once.
if (!state.get('initialNodesLoaded') && !state.get('nodesLoaded')) {
if (state.get('topologyViewMode') === GRAPH_VIEW_MODE) {
state = graphExceedsComplexityThreshSelector(state)
? state.set('topologyViewMode', TABLE_VIEW_MODE) : state;
}
state = state.set('initialNodesLoaded', true);
}
return state.set('nodesLoaded', true);
}
case ActionTypes.RECEIVE_NODES_DELTA: {
// Ignore periodic nodes updates after the first load when paused.
if (state.get('nodesLoaded') && state.get('pausedAt')) {
return state;
}
log('RECEIVE_NODES_DELTA',
'remove', size(action.delta.remove),
'update', size(action.delta.update),
'add', size(action.delta.add));
// remove nodes that no longer exist
each(action.delta.remove, (nodeId) => {
state = state.deleteIn(['nodes', nodeId]);
});
// update existing nodes
each(action.delta.update, (node) => {
if (state.hasIn(['nodes', node.id])) {
// TODO: Implement a manual deep update here, as it might bring a great benefit
// to our nodes selectors (e.g. layout engine would be completely bypassed if the
// adjacencies would stay the same but the metrics would get updated).
state = state.setIn(['nodes', node.id], fromJS(node));
}
});
// add new nodes
each(action.delta.add, (node) => {
state = state.setIn(['nodes', node.id], fromJS(node));
});
return updateStateFromNodes(state);
}
case ActionTypes.RECEIVE_NODES: {
state = state.set('timeTravelTransitioning', false);
state = state.set('nodes', fromJS(action.nodes));
state = state.set('nodesLoaded', true);
return updateStateFromNodes(state);
}
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
return state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
}
case ActionTypes.RECEIVE_NOT_FOUND: {
if (state.hasIn(['nodeDetails', action.nodeId])) {
state = state.updateIn(['nodeDetails', action.nodeId], (obj) => {
const result = Object.assign({}, obj);
result.notFound = true;
return result;
});
}
return state;
}
case ActionTypes.RECEIVE_TOPOLOGIES: {
state = state.set('errorUrl', null);
state = state.update('topologyUrlsById', topologyUrlsById => topologyUrlsById.clear());
state = processTopologies(state, action.topologies);
const currentTopologyId = state.get('currentTopologyId');
if (!currentTopologyId || !findTopologyById(state.get('topologies'), currentTopologyId)) {
state = state.set('currentTopologyId', getDefaultTopology(state.get('topologies')));
log(`Set currentTopologyId to ${state.get('currentTopologyId')}`);
}
state = setTopology(state, state.get('currentTopologyId'));
// only set on first load, if options are not already set via route
if (!state.get('topologiesLoaded') && state.get('topologyOptions').size === 0) {
state = setDefaultTopologyOptions(state, state.get('topologies'));
}
state = state.set('topologiesLoaded', true);
return state;
}
case ActionTypes.RECEIVE_API_DETAILS: {
state = state.set('errorUrl', null);
return state.merge({
hostname: action.hostname,
version: action.version,
plugins: action.plugins,
versionUpdate: action.newVersion
});
}
case ActionTypes.ROUTE_TOPOLOGY: {
state = state.set('routeSet', true);
state = state.set('pinnedSearches', makeList(action.state.pinnedSearches));
state = state.set('searchQuery', action.state.searchQuery || '');
if (state.get('currentTopologyId') !== action.state.topologyId) {
state = clearNodes(state);
}
state = setTopology(state, action.state.topologyId);
state = setDefaultTopologyOptions(state, state.get('topologies'));
state = state.merge({
selectedNodeId: action.state.selectedNodeId,
pinnedMetricType: action.state.pinnedMetricType
});
state = state.set('topologyViewMode', action.state.topologyViewMode);
if (action.state.gridSortedBy) {
state = state.set('gridSortedBy', action.state.gridSortedBy);
}
if (action.state.gridSortedDesc !== undefined) {
state = state.set('gridSortedDesc', action.state.gridSortedDesc);
}
if (action.state.showingNetworks) {
state = state.set('showingNetworks', action.state.showingNetworks);
}
if (action.state.pinnedNetwork) {
state = state.set('pinnedNetwork', action.state.pinnedNetwork);
state = state.set('selectedNetwork', action.state.pinnedNetwork);
}
if (action.state.controlPipe) {
state = state.set('controlPipes', makeOrderedMap({
[action.state.controlPipe.id]:
makeOrderedMap(action.state.controlPipe)
}));
} else {
state = state.update('controlPipes', controlPipes => controlPipes.clear());
}
if (action.state.nodeDetails) {
const actionNodeDetails = makeOrderedMap(
action.state.nodeDetails.map(obj => [obj.id, obj]));
// check if detail IDs have changed
if (!isDeepEqual(state.get('nodeDetails').keySeq(), actionNodeDetails.keySeq())) {
state = state.set('nodeDetails', actionNodeDetails);
}
} else {
state = state.update('nodeDetails', nodeDetails => nodeDetails.clear());
}
state = state.set('topologyOptions',
fromJS(action.state.topologyOptions) || state.get('topologyOptions'));
return state;
}
case ActionTypes.UNPIN_SEARCH: {
const pinnedSearches = state.get('pinnedSearches').filter(query => query !== action.query);
state = state.set('pinnedSearches', pinnedSearches);
return applyPinnedSearches(state);
}
case ActionTypes.DEBUG_TOOLBAR_INTERFERING: {
return action.fn(state);
}
case ActionTypes.TOGGLE_TROUBLESHOOTING_MENU: {
return state.set('showingTroubleshootingMenu', !state.get('showingTroubleshootingMenu'));
}
case ActionTypes.CHANGE_INSTANCE: {
state = closeAllNodeDetails(state);
return state;
}
case ActionTypes.TOGGLE_CONTRAST_MODE: {
return state.set('contrastMode', action.enabled);
}
case ActionTypes.SHUTDOWN: {
return clearNodes(state);
}
case ActionTypes.REQUEST_SERVICE_IMAGES: {
return state.setIn(['serviceImages', action.serviceId], {
isFetching: true
});
}
case ActionTypes.RECEIVE_SERVICE_IMAGES: {
const { service, errors, serviceId } = action;
return state.setIn(['serviceImages', serviceId], {
isFetching: false,
containers: service ? service.Containers : null,
errors
});
}
default: {
return state;
}
}
}
export default rootReducer;