Files
weave-scope/client/app/scripts/reducers/root.js
David Kaltschmidt c37c175dd6 Network colors from a scale
* all colors are unique and separated enough
* only 10 colors are available
* contains red and green
2016-06-16 11:46:42 +02:00

666 lines
22 KiB
JavaScript

import _ from 'lodash';
import debug from 'debug';
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 { EDGE_ID_SEPARATOR } from '../constants/naming';
import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils';
import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils';
import { findTopologyById, getAdjacentNodes, setTopologyUrlsById,
updateTopologyIds, filterHiddenTopologies } 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({
availableCanvasMetrics: makeList(),
availableNetworks: makeList(),
controlPipes: makeOrderedMap(), // pipeId -> controlPipe
controlStatus: makeMap(),
currentTopology: null,
currentTopologyId: 'containers',
errorUrl: null,
forceRelayout: false,
highlightedEdgeIds: makeSet(),
highlightedNodeIds: makeSet(),
hostname: '...',
mouseOverEdgeId: null,
mouseOverNodeId: null,
networkNodes: makeMap(),
nodeDetails: makeOrderedMap(), // nodeId -> details
nodes: makeOrderedMap(), // nodeId -> node
// nodes cache, infrequently updated, used for search
nodesByTopology: makeMap(), // topologyId -> nodes
pinnedMetric: null,
// 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.
pinnedMetricType: null,
pinnedNetwork: null,
plugins: makeList(),
pinnedSearches: makeList(), // list of node filters
routeSet: false,
searchFocused: false,
searchNodeMatches: makeMap(),
searchQuery: null,
selectedMetric: null,
selectedNetwork: null,
selectedNodeId: null,
showingHelp: false,
showingNetworks: false,
topologies: makeList(),
topologiesLoaded: false,
topologyOptions: makeOrderedMap(), // topologyId -> options
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
updatePausedAt: null, // Date
version: '...',
versionUpdate: null,
websocketClosed: true,
exportingGraph: false
});
// adds ID field to topology (based on last part of URL path) and save urls in
// map for easy lookup
function processTopologies(state, nextTopologies) {
// filter out hidden topos
const visibleTopologies = filterHiddenTopologies(nextTopologies);
// add IDs to topology objects in-place
const topologiesWithId = updateTopologyIds(visibleTopologies);
// cache URLs by ID
state = state.set('topologyUrlsById',
setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId));
const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter);
return state.mergeDeepIn(['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 resumeUpdate(state) {
return state.set('updatePausedAt', null);
}
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: {
state = resumeUpdate(state);
// set option on parent topology
const topology = findTopologyById(state.get('topologies'), action.topologyId);
if (topology) {
const topologyId = topology.get('parentId') || topology.get('id');
if (state.getIn(['topologyOptions', topologyId, action.option]) !== action.value) {
state = state.update('nodes', nodes => nodes.clear());
}
state = state.setIn(
['topologyOptions', topologyId, action.option],
action.value
);
}
return state;
}
case ActionTypes.SET_EXPORTING_GRAPH: {
return state.set('exportingGraph', action.exporting);
}
case ActionTypes.CLEAR_CONTROL_ERROR: {
return state.removeIn(['controlStatus', action.nodeId, 'error']);
}
case ActionTypes.CLICK_BACKGROUND: {
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_PAUSE_UPDATE: {
return state.set('updatePausedAt', new Date);
}
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_RESUME_UPDATE: {
return resumeUpdate(state);
}
case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: {
state = resumeUpdate(state);
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 = state.update('nodes', nodes => nodes.clear());
}
state = state.set('availableCanvasMetrics', makeList());
return state;
}
case ActionTypes.CLICK_TOPOLOGY: {
state = resumeUpdate(state);
state = closeAllNodeDetails(state);
if (action.topologyId !== state.get('currentTopologyId')) {
state = setTopology(state, action.topologyId);
state = state.update('nodes', nodes => nodes.clear());
}
state = state.set('availableCanvasMetrics', makeList());
return state;
}
case ActionTypes.CLOSE_WEBSOCKET: {
if (!state.get('websocketClosed')) {
state = state.set('websocketClosed', true);
}
return state;
}
//
// 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.SELECT_METRIC: {
return state.set('selectedMetric', action.metricId);
}
case ActionTypes.PIN_METRIC: {
const metricTypes = makeMap(
state.get('availableCanvasMetrics').map(m => [m.get('id'), m.get('label')]));
return state.merge({
pinnedMetric: action.metricId,
pinnedMetricType: metricTypes.get(action.metricId),
selectedMetric: action.metricId
});
}
case ActionTypes.UNPIN_METRIC: {
return state.merge({
pinnedMetric: null,
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: {
state = state.set('searchQuery', action.searchQuery);
return updateNodeMatches(state);
}
case ActionTypes.ENTER_EDGE: {
// highlight adjacent nodes
state = state.update('highlightedNodeIds', highlightedNodeIds => {
highlightedNodeIds = highlightedNodeIds.clear();
return highlightedNodeIds.union(action.edgeId.split(EDGE_ID_SEPARATOR));
});
// highlight edge
state = state.update('highlightedEdgeIds', highlightedEdgeIds => {
highlightedEdgeIds = highlightedEdgeIds.clear();
return highlightedEdgeIds.add(action.edgeId);
});
return state;
}
case ActionTypes.ENTER_NODE: {
const nodeId = action.nodeId;
const adjacentNodes = getAdjacentNodes(state, nodeId);
state = state.set('mouseOverNodeId', nodeId);
// highlight adjacent nodes
state = state.update('highlightedNodeIds', highlightedNodeIds => {
highlightedNodeIds = highlightedNodeIds.clear();
highlightedNodeIds = highlightedNodeIds.add(nodeId);
return highlightedNodeIds.union(adjacentNodes);
});
// highlight edge
state = state.update('highlightedEdgeIds', highlightedEdgeIds => {
highlightedEdgeIds = highlightedEdgeIds.clear();
if (adjacentNodes.size > 0) {
// all neighbour combinations because we dont know which direction exists
highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [
[adjacentId, nodeId].join(EDGE_ID_SEPARATOR),
[nodeId, adjacentId].join(EDGE_ID_SEPARATOR)
]));
}
return highlightedEdgeIds;
});
return state;
}
case ActionTypes.LEAVE_EDGE: {
state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear());
state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear());
return state;
}
case ActionTypes.LEAVE_NODE: {
state = state.set('mouseOverNodeId', null);
state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear());
state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear());
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,
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: {
state = state.set('searchQuery', '');
state = updateNodeMatches(state);
const pinnedSearches = state.get('pinnedSearches');
state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query);
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
}));
}
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: {
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.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));
}
state = state.set('errorUrl', null);
// nodes that no longer exist
_.each(action.delta.remove, (nodeId) => {
// in case node disappears before mouseleave event
if (state.get('mouseOverNodeId') === nodeId) {
state = state.set('mouseOverNodeId', null);
}
if (state.hasIn(['nodes', nodeId]) && _.includes(state.get('mouseOverEdgeId'), nodeId)) {
state = state.set('mouseOverEdgeId', null);
}
state = state.deleteIn(['nodes', nodeId]);
});
// update existing nodes
_.each(action.delta.update, (node) => {
if (state.hasIn(['nodes', node.id])) {
state = state.updateIn(['nodes', node.id], n => n.merge(fromJS(node)));
}
});
// add new nodes
_.each(action.delta.add, (node) => {
state = state.setIn(['nodes', node.id], fromJS(node));
});
// apply pinned searches, filters nodes that dont match
state = applyPinnedSearches(state);
// TODO move this setting of networks as toplevel node field to backend,
// to not rely on field IDs here. should be determined by topology implementer
state = state.update('nodes', nodes => nodes.map(node => {
if (node.has('metadata')) {
const networks = node.get('metadata')
.find(field => field.get('id') === 'docker_container_networks');
if (networks) {
return node.set('networks', fromJS(
networks.get('value').split(', ').map(n => ({id: n, label: n, colorKey: n}))));
}
}
return node;
}));
state = state.set('networkNodes', getNetworkNodes(state.get('nodes')));
state = state.set('availableNetworks', getAvailableNetworks(state.get('nodes')));
state = state.set('availableCanvasMetrics', state.get('nodes')
.valueSeq()
.flatMap(n => (n.get('metrics') || makeList()).map(m => (
makeMap({id: m.get('id'), label: m.get('label')})
)))
.toSet()
.toList()
.sortBy(m => m.get('label')));
const similarTypeMetric = state.get('availableCanvasMetrics')
.find(m => m.get('label') === state.get('pinnedMetricType'));
state = state.set('pinnedMetric', similarTypeMetric && similarTypeMetric.get('id'));
// if something in the current topo is not already selected, select it.
if (!state.get('availableCanvasMetrics')
.map(m => m.get('id'))
.toSet()
.has(state.get('selectedMetric'))) {
state = state.set('selectedMetric', state.get('pinnedMetric'));
}
// update nodes cache and search results
state = state.setIn(['nodesByTopology', state.get('currentTopologyId')], state.get('nodes'));
state = updateNodeMatches(state);
return state;
}
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
// not sure if mergeDeep() brings any benefit here
state = state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
state = updateNodeMatches(state);
return state;
}
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);
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 = state.update('nodes', nodes => nodes.clear());
}
state = setTopology(state, action.state.topologyId);
state = setDefaultTopologyOptions(state, state.get('topologies'));
state = state.merge({
selectedNodeId: action.state.selectedNodeId,
pinnedMetricType: action.state.pinnedMetricType
});
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);
}
default: {
return state;
}
}
}
export default rootReducer;