/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */ import debug from 'debug'; import 'moment/locale/zh-cn' moment.locale('zh-cn'); 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 { isPausedSelector } from '../selectors/time-travel'; 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({ capabilities: 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 // nodes cache, infrequently updated, used for search & resource view // topologyId -> nodes nodesByTopology: makeMap(), nodesLoaded: false, // 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, // list of node filters pinnedSearches: makeList(), plugins: makeList(), routeSet: false, searchFocused: false, searchQuery: '', selectedNetwork: null, selectedNodeId: null, showingHelp: false, showingNetworks: false, showingTroubleshootingMenu: false, storeViewState: true, timeTravelTransitioning: false, topologies: makeList(), topologiesLoaded: false, topologyOptions: makeOrderedMap(), // topologyId -> options topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl topologyViewMode: GRAPH_VIEW_MODE, version: null, versionUpdate: null, // Set some initial numerical values to prevent NaN in case of edgy race conditions. viewport: makeMap({ height: 0, width: 0 }), websocketClosed: false, zoomCache: 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('currentTopology')); // 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); } export function getDefaultTopologyOptions(state) { let topologyOptions = makeOrderedMap(); state.get('topologies').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) { topologyOptions = topologyOptions.set(topology.get('id'), defaultOptions); } }); return topologyOptions; } 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.FOCUS_SEARCH: { return state.set('searchFocused', true); } 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(optionKey); 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'], { height: action.height, width: action.width, }); } 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.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); state.set('nodeShape', action.origin) // 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, shape:action.origin, origin, topologyId: action.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); return state.set('pausedAt', null); } case ActionTypes.PAUSE_TIME_AT_NOW: { state = state.set('timeTravelTransitioning', false); // return state.set('pausedAt', moment().utc().format()); return state.set('pausedAt', moment().utcOffset(8).format()) } case ActionTypes.JUMP_TO_TIME: { state = state.set('timeTravelTransitioning', true); return state.set('pausedAt', action.timestamp); } 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({ error: null, pending: true })); } 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({ error: action.error, pending: false })); } case ActionTypes.DO_CONTROL_SUCCESS: { return state.setIn(['controlStatus', action.nodeId], makeMap({ error: null, pending: false })); } case ActionTypes.UPDATE_SEARCH: { state = state.set('pinnedSearches', makeList(action.pinnedSearches)); state = state.set('searchQuery', action.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({ control: action.control, id: action.pipeId, nodeId: action.nodeId, raw: action.rawTty, resizeTtyControl: action.resizeTtyControl })); } 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: { // Ignore the update if paused and the timestamp didn't change. const setTimestamp = state.getIn(['nodeDetails', action.details.id, 'timestamp']); if (isPausedSelector(state) && action.requestTimestamp === setTimestamp) { 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 => ({ ...obj, details: action.details, notFound: false, timestamp: action.requestTimestamp, })); } 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), 'reset', action.delta.reset ); if (action.delta.reset) { state = state.set('nodes', makeMap()); } // 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 => ({ ...obj, notFound: true, timestamp: action.requestTimestamp, })); } 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')); // Expand topology options with topologies' defaults on first load, but let // the current state of topologyOptions (which at this point reflects the // URL state) still take the precedence over defaults. if (!state.get('topologiesLoaded')) { const options = getDefaultTopologyOptions(state).mergeDeep(state.get('topologyOptions')); state = state.set('topologyOptions', options); state = state.set('topologiesLoaded', true); } return state; } case ActionTypes.RECEIVE_API_DETAILS: { state = state.set('errorUrl', null); return state.merge({ capabilities: action.capabilities, hostname: action.hostname, plugins: action.plugins, version: action.version, 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 = state.merge({ pinnedMetricType: action.state.pinnedMetricType, selectedNodeId: action.state.selectedNodeId, }); if (action.state.topologyOptions) { const options = getDefaultTopologyOptions(state).mergeDeep(action.state.topologyOptions); state = state.set('topologyOptions', options); } if (action.state.topologyViewMode) { 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.contrastMode !== undefined) { state = state.set('contrastMode', action.state.contrastMode); } 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(h => [h.id, h])); // 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()); } return 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.MONITOR_STATE: { return state.set('monitor', action.monitor); } case ActionTypes.SET_STORE_VIEW_STATE: { return state.set('storeViewState', action.storeViewState); } default: { return state; } } } export default rootReducer;