From 5a325e46fa3a29586b3f396f305b4b945c7a5127 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 28 Apr 2016 11:27:17 +0200 Subject: [PATCH 01/17] Search on canvas * adds a search field next to the topologies * highlight results on canvas as you type * non-matching nodes are grayed out * "prefix:" limits search to field label --- client/app/scripts/actions/app-actions.js | 33 ++- client/app/scripts/charts/node.js | 24 ++- .../app/scripts/charts/nodes-chart-edges.js | 11 +- .../app/scripts/charts/nodes-chart-nodes.js | 16 +- client/app/scripts/components/app.js | 2 + .../app/scripts/components/matched-results.js | 52 +++++ client/app/scripts/components/matched-text.js | 71 +++++++ client/app/scripts/components/node-details.js | 18 +- .../node-details/node-details-info.js | 17 +- .../node-details/node-details-labels.js | 14 +- client/app/scripts/components/search.js | 103 ++++++++++ client/app/scripts/components/topologies.js | 30 ++- client/app/scripts/constants/action-types.js | 4 + client/app/scripts/reducers/root.js | 59 ++++-- client/app/scripts/utils/search-utils.js | 102 ++++++++++ client/app/scripts/utils/topology-utils.js | 4 + client/app/scripts/utils/web-api-utils.js | 17 +- client/app/styles/main.less | 189 +++++++++++++++++- 18 files changed, 706 insertions(+), 60 deletions(-) create mode 100644 client/app/scripts/components/matched-results.js create mode 100644 client/app/scripts/components/matched-text.js create mode 100644 client/app/scripts/components/search.js create mode 100644 client/app/scripts/utils/search-utils.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 1202cd371..f87d25d01 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -6,7 +6,7 @@ import { modulo } from '../utils/math-utils'; import { updateRoute } from '../utils/router-utils'; import { bufferDeltaUpdate, resumeUpdate, resetUpdateBuffer } from '../utils/update-buffer-utils'; -import { doControlRequest, getNodesDelta, getNodeDetails, +import { doControlRequest, getAllNodes, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; import { getActiveTopologyOptions, getCurrentTopologyUrl } from '../utils/topology-utils'; @@ -69,6 +69,10 @@ export function pinNextMetric(delta) { }; } +export function blurSearch() { + return { type: ActionTypes.BLUR_SEARCH }; +} + export function changeTopologyOption(option, value, topologyId) { return (dispatch, getState) => { dispatch({ @@ -265,6 +269,13 @@ export function doControl(nodeId, control) { }; } +export function doSearch(searchQuery) { + return { + type: ActionTypes.DO_SEARCH, + searchQuery + }; +} + export function enterEdge(edgeId) { return { type: ActionTypes.ENTER_EDGE, @@ -279,12 +290,25 @@ export function enterNode(nodeId) { }; } +export function focusSearch() { + return (dispatch, getState) => { + dispatch({ type: ActionTypes.FOCUS_SEARCH }); + // update nodes cache to allow search across all topologies, + // wait a second until animation is over + setTimeout(() => { + getAllNodes(getState, dispatch); + }, 1200); + }; +} + export function hitEsc() { return (dispatch, getState) => { const state = getState(); const controlPipe = state.get('controlPipes').last(); if (state.get('showingHelp')) { dispatch(hideHelp()); + } else if (state.get('searchQuery')) { + dispatch(doSearch('')); } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { dispatch({ type: ActionTypes.CLICK_CLOSE_TERMINAL, @@ -351,6 +375,13 @@ export function receiveNodesDelta(delta) { }; } +export function receiveNodesForTopology(nodes, topologyId) { + return { + type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY, + nodes, + topologyId + }; +} export function receiveTopologies(topologies) { return (dispatch, getState) => { diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 5d96faa41..7a6d359f6 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -5,6 +5,8 @@ import classNames from 'classnames'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; import { getNodeColor } from '../utils/color-utils'; +import MatchedText from '../components/matched-text'; +import MatchedResults from '../components/matched-results'; import NodeShapeCircle from './node-shape-circle'; import NodeShapeStack from './node-shape-stack'; @@ -55,7 +57,7 @@ class Node extends React.Component { } render() { - const { blurred, focused, highlighted, label, pseudo, rank, + const { blurred, focused, highlighted, label, matched, matches, pseudo, rank, subLabel, scaleFactor, transform, zoomScale } = this.props; const { hovered } = this.state; const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale; @@ -83,6 +85,7 @@ class Node extends React.Component { highlighted, blurred, hovered, + matched, pseudo }); @@ -97,14 +100,17 @@ class Node extends React.Component { width={nodeScale(scaleFactor)} height={nodeScale(scaleFactor) + subLabelOffsetY} /> - - {labelText} - - - {subLabelText} - + +
+ +
+
+ +
+ +
{layoutEdges.toIndexedSeq().map(edge => { const sourceSelected = selectedNodeId === edge.get('source'); const targetSelected = selectedNodeId === edge.get('target'); - const blurred = hasSelectedNode && !sourceSelected && !targetSelected; + const blurred = hasSelectedNode && !sourceSelected && !targetSelected + || searchNodeMatches.size > 0 && !(searchNodeMatches.has(edge.get('source')) + && searchNodeMatches.has(edge.get('target'))); const focused = hasSelectedNode && (sourceSelected || targetSelected); return ( @@ -37,7 +40,9 @@ class NodesChartEdges extends React.Component { } function mapStateToProps(state) { + const currentTopologyId = state.get('currentTopologyId'); return { + searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), hasSelectedNode: hasSelectedNodeFn(state), selectedNodeId: state.get('selectedNodeId'), highlightedEdgeIds: state.get('highlightedEdgeIds') diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index b9447e507..af8f5b748 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { fromJS } from 'immutable'; +import { fromJS, Map as makeMap } from 'immutable'; import { getAdjacentNodes } from '../utils/topology-utils'; import NodeContainer from './node-container'; @@ -8,8 +8,8 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, - nodeScale, scale, selectedMetric, selectedNodeScale, selectedNodeId, - topologyId, topCardNode } = this.props; + nodeScale, scale, searchNodeMatches = makeMap(), selectedMetric, selectedNodeScale, + selectedNodeId, topCardNode } = this.props; const zoomScale = scale; @@ -19,7 +19,9 @@ class NodesChartNodes extends React.Component { const setFocused = node => node.set('focused', selectedNodeId && (selectedNodeId === node.get('id') || (adjacentNodes && adjacentNodes.includes(node.get('id'))))); - const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused')); + const setBlurred = node => node.set('blurred', + selectedNodeId && !node.get('focused') + || searchNodeMatches.size > 0 && !searchNodeMatches.has(node.get('id'))); // make sure blurred nodes are in the background const sortNodes = node => { @@ -52,8 +54,9 @@ class NodesChartNodes extends React.Component { {nodesToRender.map(node => + diff --git a/client/app/scripts/components/matched-results.js b/client/app/scripts/components/matched-results.js new file mode 100644 index 000000000..d860a7e6f --- /dev/null +++ b/client/app/scripts/components/matched-results.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import MatchedText from './matched-text'; + +const SHOW_ROW_COUNT = 3; + +class MatchedResults extends React.Component { + + renderMatch(matches, field) { + const match = matches.get(field); + return ( +
+
+ + {match.label}: + + +
+
+ ); + } + + render() { + const { matches } = this.props; + + if (!matches) { + return null; + } + + let moreFieldMatches; + let moreFieldMatchesTitle; + if (matches.size > SHOW_ROW_COUNT) { + moreFieldMatches = matches + .valueSeq() + .skip(SHOW_ROW_COUNT) + .map(field => field.label); + moreFieldMatchesTitle = `More matches:\n${moreFieldMatches.join(',\n')}`; + } + + return ( +
+ {matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))} + {moreFieldMatches && + {`${moreFieldMatches.size} more matches`} + } +
+ ); + } +} + +export default connect()(MatchedResults); diff --git a/client/app/scripts/components/matched-text.js b/client/app/scripts/components/matched-text.js new file mode 100644 index 000000000..a981a992b --- /dev/null +++ b/client/app/scripts/components/matched-text.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +/** + * Returns an array with chunks that cover the whole text via {start, length} + * objects. + * + * `([{start: 2, length: 1}], "text") => + * [{start: 0, length: 2}, {start: 2, length: 1, match: true}, {start: 3, length: 1}]` + */ +function reduceMatchesToChunks(matches, text) { + if (text && matches && matches.length > 0) { + const result = matches.reduce((chunks, match) => { + const prev = chunks.length > 0 ? chunks[chunks.length - 1] : null; + const end = prev ? prev.start + prev.length : 0; + // skip non-matching chunk if first chunk is match + if (match.start > 0) { + chunks.push({start: end, length: match.start}); + } + chunks.push(Object.assign({match: true}, match)); + return chunks; + }, []); + const last = result[result.length - 1]; + const remaining = last.start + last.length; + if (text && remaining < text.length) { + result.push({start: remaining, length: text.length - remaining}); + } + return result; + } + return []; +} + +/** + * Renders text with highlighted search matches. + * + * `props.matches` must be an immutable.Map of match + * objects, the match object for this component will be extracted + * via `get(props.fieldId)`). + * A match object is of shape `{text, label, matches}`. + * `match.matches` is an array of text matches of shape `{start, length}` + * that delimit text matches in `text`. `label` shows the origin of the text. + */ +class MatchedText extends React.Component { + + render() { + const { fieldId, matches, text } = this.props; + // match is a direct match object, or still need to extract the correct field + const fieldMatches = matches && matches.get(fieldId); + + if (!fieldMatches) { + return {text}; + } + + return ( + + {reduceMatchesToChunks(fieldMatches.matches, text).map((chunk, index) => { + if (chunk.match) { + return ( + + {text.substr(chunk.start, chunk.length)} + + ); + } + return text.substr(chunk.start, chunk.length); + })} + + ); + } +} + +export default connect()(MatchedText); diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 6bd6017e3..6ca113c6a 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -1,11 +1,13 @@ import _ from 'lodash'; import React from 'react'; import { connect } from 'react-redux'; +import { Map as makeMap } from 'immutable'; import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-actions'; import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils'; import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils'; +import MatchedText from './matched-text'; import NodeDetailsControls from './node-details/node-details-controls'; import NodeDetailsHealth from './node-details/node-details-health'; import NodeDetailsInfo from './node-details/node-details-info'; @@ -140,11 +142,10 @@ export class NodeDetails extends React.Component { } renderDetails() { - const details = this.props.details; + const { details, nodeControlStatus, nodeMatches = makeMap() } = this.props; const showControls = details.controls && details.controls.length > 0; const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo); - const {error, pending} = this.props.nodeControlStatus - ? this.props.nodeControlStatus.toJS() : {}; + const {error, pending} = nodeControlStatus ? nodeControlStatus.toJS() : {}; const tools = this.renderTools(); const styles = { controls: { @@ -161,7 +162,7 @@ export class NodeDetails extends React.Component {

- {details.label} +

{details.parents && } @@ -183,7 +184,7 @@ export class NodeDetails extends React.Component {
} {details.metadata &&
Info
- +
} {details.connections && details.connections.map(connections =>
}
- +
); } @@ -230,8 +232,10 @@ export class NodeDetails extends React.Component { } } -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { + const currentTopologyId = state.get('currentTopologyId'); return { + nodeMatches: state.getIn(['searchNodeMatches', currentTopologyId, ownProps.id]), nodes: state.get('nodes') }; } diff --git a/client/app/scripts/components/node-details/node-details-info.js b/client/app/scripts/components/node-details/node-details-info.js index 1a076d694..b41e850dd 100644 --- a/client/app/scripts/components/node-details/node-details-info.js +++ b/client/app/scripts/components/node-details/node-details-info.js @@ -1,5 +1,6 @@ import React from 'react'; +import MatchedText from '../matched-text'; import ShowMore from '../show-more'; export default class NodeDetailsInfo extends React.Component { @@ -18,13 +19,21 @@ export default class NodeDetailsInfo extends React.Component { } render() { + const { matches } = this.props; let rows = (this.props.rows || []); - const prime = rows.filter(row => row.priority < 10); let notShown = 0; + + const prime = rows.filter(row => row.priority < 10); if (!this.state.expanded && prime.length < rows.length) { - notShown = rows.length - prime.length; - rows = prime; + // check if there is a search match in non-prime fields + const hasNonPrimeMatch = matches && rows.filter(row => row.priority >= 10 + && matches.has(row.id)).length > 0; + if (!hasNonPrimeMatch) { + notShown = rows.length - prime.length; + rows = prime; + } } + return (
{rows.map(field => (
@@ -32,7 +41,7 @@ export default class NodeDetailsInfo extends React.Component { {field.label}
- {field.value} +
))} diff --git a/client/app/scripts/components/node-details/node-details-labels.js b/client/app/scripts/components/node-details/node-details-labels.js index 362ff4e19..198aa49d2 100644 --- a/client/app/scripts/components/node-details/node-details-labels.js +++ b/client/app/scripts/components/node-details/node-details-labels.js @@ -1,5 +1,7 @@ import React from 'react'; +import { Map as makeMap } from 'immutable'; +import MatchedText from '../matched-text'; import ShowMore from '../show-more'; export default class NodeDetailsLabels extends React.Component { @@ -19,12 +21,18 @@ export default class NodeDetailsLabels extends React.Component { } render() { + const { matches = makeMap() } = this.props; let rows = this.props.rows; + let notShown = 0; const limited = rows && this.state.limit > 0 && rows.length > this.state.limit; const expanded = this.state.limit === 0; - const notShown = rows.length - this.DEFAULT_LIMIT; if (rows && limited) { - rows = rows.slice(0, this.state.limit); + const hasNotShownMatch = rows.filter((row, index) => index >= this.state.limit + && matches.has(row.id)).length > 0; + if (!hasNotShownMatch) { + notShown = rows.length - this.DEFAULT_LIMIT; + rows = rows.slice(0, this.state.limit); + } } return ( @@ -35,7 +43,7 @@ export default class NodeDetailsLabels extends React.Component { {field.label}
- {field.value} +
))} diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js new file mode 100644 index 000000000..f45212947 --- /dev/null +++ b/client/app/scripts/components/search.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import cx from 'classnames'; +import _ from 'lodash'; + +import { blurSearch, doSearch, focusSearch } from '../actions/app-actions'; +import { isTopologyEmpty } from '../utils/topology-utils'; + +const SEARCH_HINTS = [ + 'Try "db" or "app1" to search by node label or sublabel.', + 'Try "sublabel:my-host" to search by node sublabel.', + 'Try "label:my-node" to search by node name.', + 'Try "metadata:my-node" to search through all node metadata.', + 'Try "dockerenv:my-env-value" to search through all docker environment variables.', + 'Try "all:my-value" to search through all metdata and labels.' +]; + +// every minute different hint +function getHint() { + return SEARCH_HINTS[(new Date).getMinutes() % SEARCH_HINTS.length]; +} + +class Search extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleBlur = this.handleBlur.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleFocus = this.handleFocus.bind(this); + this.doSearch = _.debounce(this.doSearch.bind(this), 200); + this.state = { + value: '' + }; + } + + handleBlur() { + this.props.blurSearch(); + } + + handleChange(ev) { + const value = ev.target.value; + this.setState({value}); + this.doSearch(value); + } + + handleFocus() { + this.props.focusSearch(); + } + + doSearch(value) { + this.props.doSearch(value); + } + + componentWillReceiveProps(nextProps) { + // when cleared from the outside, reset internal state + if (this.props.searchQuery !== nextProps.searchQuery && nextProps.searchQuery === '') { + this.setState({value: ''}); + } + } + + render() { + const inputId = this.props.inputId || 'search'; + const disabled = this.props.isTopologyEmpty || !this.props.topologiesLoaded; + const matchCount = this.props.searchNodeMatches + .reduce((count, topologyMatches) => count + topologyMatches.size, 0); + const classNames = cx('search', { + 'search-matched': matchCount, + 'search-filled': this.state.value, + 'search-focused': this.props.searchFocused, + 'search-disabled': disabled + }); + const title = matchCount ? `${matchCount} matches` : null; + + return ( +
+
+
+ + +
+
{getHint()}
+
+
+ ); + } +} + +export default connect( + state => ({ + isTopologyEmpty: isTopologyEmpty(state), + searchFocused: state.get('searchFocused'), + searchQuery: state.get('searchQuery'), + searchNodeMatches: state.get('searchNodeMatches'), + topologiesLoaded: state.get('topologiesLoaded') + }), + { blurSearch, doSearch, focusSearch } +)(Search); diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js index 84d8b75e9..ea631ba63 100644 --- a/client/app/scripts/components/topologies.js +++ b/client/app/scripts/components/topologies.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import cx from 'classnames'; import { clickTopology } from '../actions/app-actions'; @@ -18,9 +19,13 @@ class Topologies extends React.Component { renderSubTopology(subTopology) { const isActive = subTopology === this.props.currentTopology; const topologyId = subTopology.get('id'); - const title = this.renderTitle(subTopology); - const className = isActive - ? 'topologies-sub-item topologies-sub-item-active' : 'topologies-sub-item'; + const searchMatches = this.props.searchNodeMatches.get(subTopology.get('id')); + const searchMatchCount = searchMatches ? searchMatches.size : 0; + const title = this.renderTitle(subTopology, searchMatchCount); + const className = cx('topologies-sub-item', { + 'topologies-sub-item-active': isActive, + 'topologies-sub-item-matched': searchMatchCount + }); return (
@@ -73,6 +86,7 @@ class Topologies extends React.Component { function mapStateToProps(state) { return { topologies: state.get('topologies'), + searchNodeMatches: state.get('searchNodeMatches'), currentTopology: state.get('currentTopology') }; } diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 773143829..731b675ba 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -1,6 +1,7 @@ import _ from 'lodash'; const ACTION_TYPES = [ + 'BLUR_SEARCH', 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', 'CLICK_BACKGROUND', @@ -19,8 +20,10 @@ const ACTION_TYPES = [ 'DO_CONTROL', 'DO_CONTROL_ERROR', 'DO_CONTROL_SUCCESS', + 'DO_SEARCH', 'ENTER_EDGE', 'ENTER_NODE', + 'FOCUS_SEARCH', 'HIDE_HELP', 'LEAVE_EDGE', 'LEAVE_NODE', @@ -33,6 +36,7 @@ const ACTION_TYPES = [ 'RECEIVE_NODE_DETAILS', 'RECEIVE_NODES', 'RECEIVE_NODES_DELTA', + 'RECEIVE_NODES_FOR_TOPOLOGY', 'RECEIVE_NOT_FOUND', 'RECEIVE_TOPOLOGIES', 'RECEIVE_API_DETAILS', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index e9711554b..c785aaf75 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -5,6 +5,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, import ActionTypes from '../constants/action-types'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { updateNodeMatches } from '../utils/search-utils'; import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; @@ -33,7 +34,8 @@ const topologySorter = topology => topology.get('rank'); // Initial values export const initialState = makeMap({ - topologyOptions: makeOrderedMap(), // topologyId -> options + availableCanvasMetrics: makeList(), + controlPipes: makeOrderedMap(), // pipeId -> controlPipe controlStatus: makeMap(), currentTopology: null, currentTopologyId: 'containers', @@ -42,29 +44,32 @@ export const initialState = makeMap({ highlightedEdgeIds: makeSet(), highlightedNodeIds: makeSet(), hostname: '...', - version: '...', - versionUpdate: null, - plugins: makeList(), mouseOverEdgeId: null, mouseOverNodeId: null, nodeDetails: makeOrderedMap(), // nodeId -> details nodes: makeOrderedMap(), // nodeId -> node - selectedNodeId: null, - topologies: makeList(), - topologiesLoaded: false, - topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl - routeSet: false, - controlPipes: makeOrderedMap(), // pipeId -> controlPipe - updatePausedAt: null, // Date - websocketClosed: true, - showingHelp: false, - - selectedMetric: null, + // 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, - availableCanvasMetrics: makeList() + plugins: makeList(), + routeSet: false, + searchFocused: false, + searchNodeMatches: makeMap(), + searchQuery: null, + selectedMetric: null, + selectedNodeId: null, + showingHelp: false, + topologies: makeList(), + topologiesLoaded: false, + topologyOptions: makeOrderedMap(), // topologyId -> options + topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl + updatePausedAt: null, // Date + version: '...', + versionUpdate: null, + websocketClosed: true }); // adds ID field to topology (based on last part of URL path) and save urls in @@ -142,6 +147,10 @@ export function rootReducer(state = initialState, 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 @@ -305,6 +314,11 @@ export function rootReducer(state = initialState, action) { })); } + 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 => { @@ -380,6 +394,10 @@ export function rootReducer(state = initialState, action) { })); } + case ActionTypes.FOCUS_SEARCH: { + return state.set('searchFocused', true); + } + case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { return closeNodeDetails(state, action.nodeId); } @@ -478,9 +496,18 @@ export function rootReducer(state = initialState, action) { 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 + 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 => { diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js new file mode 100644 index 000000000..5f0f796af --- /dev/null +++ b/client/app/scripts/utils/search-utils.js @@ -0,0 +1,102 @@ +import { Map as makeMap } from 'immutable'; + +const SEARCH_FIELDS = makeMap({ + label: 'label', + sublabel: 'label_minor' +}); + +// TODO make this dynamic based on topology +const SEARCH_TABLES = makeMap({ + dockerlabel: 'docker_label_', + dockerenv: 'docker_env_', +}); + +const PREFIX_DELIMITER = ':'; +const PREFIX_ALL = 'all'; +const PREFIX_ALL_SHORT = 'a'; +const PREFIX_METADATA = 'metadata'; +const PREFIX_METADATA_SHORT = 'm'; + +function findNodeMatch(nodeMatches, keyPath, text, query, label) { + const index = text.indexOf(query); + if (index > -1) { + nodeMatches = nodeMatches.setIn(keyPath, + {text, label, matches: [{start: index, length: query.length}]}); + } + return nodeMatches; +} + +function searchTopology(nodes, searchFields, prefix, query) { + let nodeMatches = makeMap(); + nodes.forEach((node, nodeId) => { + // top level fields + searchFields.forEach((field, label) => { + const keyPath = [nodeId, label]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), query, label); + }); + + // metadata + if (node.get('metadata') && (prefix === PREFIX_METADATA || prefix === PREFIX_METADATA_SHORT + || prefix === PREFIX_ALL || prefix === PREFIX_ALL_SHORT)) { + node.get('metadata').forEach(field => { + const keyPath = [nodeId, 'metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, field.get('label')); + }); + } + + // tables (envvars and labels) + const tables = node.get('tables'); + if (tables) { + let searchTables; + if (prefix === PREFIX_ALL || prefix === PREFIX_ALL_SHORT) { + searchTables = SEARCH_TABLES; + } else if (prefix) { + searchTables = SEARCH_TABLES.filter((field, label) => prefix === label); + } + if (searchTables && searchTables.size > 0) { + searchTables.forEach((searchTable) => { + const table = tables.find(t => t.get('id') === searchTable); + if (table && table.get('rows')) { + table.get('rows').forEach(field => { + const keyPath = [nodeId, 'metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, field.get('label')); + }); + } + }); + } + } + }); + return nodeMatches; +} + +/** + * Returns {topologyId: {nodeId: matches}} + */ +export function updateNodeMatches(state) { + let query = state.get('searchQuery'); + const prefixQuery = query && query.split(PREFIX_DELIMITER); + const isPrefixQuery = prefixQuery && prefixQuery.length === 2; + const isValidPrefixQuery = isPrefixQuery && prefixQuery.every(s => s); + + if (query && (isPrefixQuery === isValidPrefixQuery)) { + const prefix = isValidPrefixQuery ? prefixQuery[0] : null; + let searchFields = SEARCH_FIELDS; + if (isPrefixQuery) { + query = prefixQuery[1]; + searchFields = searchFields.filter((field, label) => label === prefix); + } + state.get('topologyUrlsById').forEach((url, topologyId) => { + const topologyNodes = state.getIn(['nodesByTopology', topologyId]); + if (topologyNodes) { + const nodeMatches = searchTopology(topologyNodes, searchFields, prefix, query); + state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); + } + }); + } else { + state = state.update('searchNodeMatches', snm => snm.clear()); + } + + return state; +} diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 37307cdd6..4da44970a 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -137,3 +137,7 @@ export function isSameTopology(nodes, nextNodes) { const nextTopology = nextNodes.map(mapper); return isDeepEqual(topology, nextTopology); } + +export function isNodeMatchingQuery(node, query) { + return node.get('label').includes(query) || node.get('subLabel').includes(query); +} diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 0f8810cf4..570ac25a5 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -4,7 +4,8 @@ import reqwest from 'reqwest'; import { clearControlError, closeWebsocket, openWebsocket, receiveError, receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError, receiveControlNodeRemoved, receiveControlPipe, receiveControlPipeStatus, - receiveControlSuccess, receiveTopologies, receiveNotFound } from '../actions/app-actions'; + receiveControlSuccess, receiveTopologies, receiveNotFound, + receiveNodesForTopology } from '../actions/app-actions'; import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer'; @@ -95,6 +96,20 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) { /* keep URLs relative */ +/** + * Gets nodes for all topologies (for search) + */ +export function getAllNodes(getState, dispatch) { + const state = getState(); + const topologyOptions = state.get('topologyOptions'); + state.get('topologyUrlsById').forEach((topologyUrl, topologyId) => { + const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId)); + fetch(`${topologyUrl}?${optionsQuery}`) + .then(response => response.json()) + .then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))); + }); +} + export function getTopologies(options, dispatch) { clearTimeout(topologyTimer); const optionsQuery = buildOptionsQuery(options); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 43132ba89..43db6a9cc 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -170,7 +170,7 @@ h2 { display: flex; .logo { - margin: -8px 0 0 64px; + margin: -10px 0 0 64px; height: 64px; width: 250px; } @@ -236,7 +236,7 @@ h2 { } .topologies { - margin: 4px 0 0 128px; + margin: 8px 4px; display: flex; .topologies-item { @@ -268,6 +268,7 @@ h2 { border-radius: @border-radius; opacity: 0.8; margin-bottom: 3px; + border: 1px solid transparent; &-active, &:hover { color: @text-color; @@ -277,6 +278,11 @@ h2 { &-active { opacity: 0.85; } + + &-matched { + border-color: @weave-blue; + } + } .topologies-sub-item { @@ -375,6 +381,19 @@ h2 { &.blurred { opacity: @node-opacity-blurred; } + + &.matched .shape { + animation: throb 0.5s @base-ease; + } + + .node-label, .node-sublabel { + text-align: center; + } + + .match { + background-color: lighten(rgba(0, 210, 255, 0.5), 30%); + border: 1px solid @weave-blue; + } } .edge { @@ -423,6 +442,8 @@ h2 { } .shape { + transform: scale(1); + /* cloud paths have stroke-width set dynamically */ &:not(.shape-cloud) .border { stroke-width: @node-border-stroke-width; @@ -474,6 +495,32 @@ h2 { } +.matched-results { + text-align: center; + + &-match { + font-size: 0.7rem; + + &-wrapper { + display: inline-block; + margin: 1px; + padding: 2px 4px; + background-color: fade(@weave-blue, 10%); + } + + &-label { + color: @text-secondary-color; + margin-right: 0.5em; + } + } + + &-more { + text-transform: uppercase; + font-size: 0.7rem; + color: @text-tertiary-color; + } +} + .details { &-wrapper { position: fixed; @@ -526,6 +573,11 @@ h2 { } } + .match { + background-color: fade(@weave-blue, 30%); + border: 1px solid @weave-blue; + } + &-header { .colorable; @@ -1141,6 +1193,131 @@ h2 { font-size: .7rem; } +.search { + display: inline-block; + position: relative; + width: 10em; + transition: width 0.3s 0s @base-ease; + + &-wrapper { + flex: 0 1 25%; + margin: 8px; + text-align: right; + } + + &-disabled { + opacity: 0.5; + cursor: disabled; + } + + &-hint { + font-size: 0.7rem; + position: absolute; + padding: 0 1em 0 3em; + color: @text-tertiary-color; + top: 0; + opacity: 0; + transition: transform 0.3s 0s @base-ease, opacity 0.3s 0s @base-ease; + text-align: left; + } + + &-input { + overflow: hidden; + background: #fff; + position: relative; + z-index: 1; + display: inline-block; + border-radius: @border-radius; + width: 100%; + border: 1px solid transparent; + + &-field { + font-size: 0.8rem; + line-height: 150%; + position: relative; + display: block; + float: right; + padding: 4px 8px 4px 32px; + width: 100%; + border: none; + border-radius: 0; + background: transparent; + color: @text-color; + + &:focus { + outline: none; + } + } + + &-label { + user-select: none; + display: inline-block; + float: right; + padding: 0 0.75em; + font-size: 0.8rem; + position: absolute; + top: -10px; + width: 100%; + text-align: left; + pointer-events: none; + color: @text-secondary-color; + + &-icon { + top: 10px; + position: relative; + width: 1.285em; + text-align: center; + color: @text-secondary-color; + transition: opacity 0.3s 0.4s @base-ease; + opacity: 0; + display: inline-block; + } + + &-text { + color: @text-secondary-color; + text-align: left; + padding: 4px 0; + top: 10px; + position: relative; + left: -1.2em; + transition: opacity 0.3s 0.5s @base-ease; + opacity: 1; + display: inline-block; + text-transform: uppercase; + } + } + } + + &-focused &-input-label-icon, + &-filled &-input-label-icon { + transition: opacity 0.3s 0s @base-ease; + opacity: 1; + } + + &-focused &-input-label-text, + &-filled &-input-label-text { + transition: opacity 0.1s 0s @base-ease; + opacity: 0; + } + + &-focused &-hint, + &-filled &-hint { + opacity: 1; + transform: translate3d(0, 2.75em, 0); + transition: transform 0.3s 0.3s @base-ease, opacity 0.3s 0.3s @base-ease; + } + + &-focused, + &-filled { + width: 100%; + } + + &-matched &-input { + border-color: @weave-blue; + } + +} + @keyframes focusing { 0% { opacity: 0; @@ -1159,6 +1336,14 @@ h2 { } } +@keyframes throb { + 0%, 50%, 100% { + transform: scale(1); + } 25%, 75% { + transform: scale(1.2); + } +} + // // Help panel! // From 8669266f2529f8daefe107315b025f8ac6518561 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 29 Apr 2016 17:53:17 +0200 Subject: [PATCH 02/17] Toggle debug toolbar via ctrl-D --- client/app/scripts/components/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index a7e396b8e..d50aaf601 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -75,7 +75,7 @@ class App extends React.Component { } else if (char === 'q') { dispatch(unpinMetric()); dispatch(selectMetric(null)); - } else if (char === 'd') { + } else if (ev.code === 'KeyD' && ev.ctrlKey) { toggleDebugToolbar(); this.forceUpdate(); } else if (char === '?') { From 3ee802a516f7efc9c362d18ff1148fe79685c87c Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 3 May 2016 15:58:09 +0200 Subject: [PATCH 03/17] Search all fields by default, gray out nodes if no match --- client/app/scripts/charts/node.js | 8 +- .../app/scripts/charts/nodes-chart-edges.js | 11 +-- .../app/scripts/charts/nodes-chart-nodes.js | 6 +- client/app/scripts/components/search.js | 31 +++++--- client/app/scripts/utils/search-utils.js | 78 +++++++++---------- client/app/scripts/utils/string-utils.js | 5 ++ 6 files changed, 73 insertions(+), 66 deletions(-) diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 7a6d359f6..1c8ecda43 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -67,8 +67,7 @@ class Node extends React.Component { const labelText = truncate ? ellipsis(label, 14, nodeScale(4 * scaleFactor)) : label; const subLabelText = truncate ? ellipsis(subLabel, 12, nodeScale(4 * scaleFactor)) : subLabel; - let labelOffsetY = 18; - let subLabelOffsetY = 35; + let labelOffsetY = 8; let labelFontSize = 14; let subLabelFontSize = 12; @@ -77,7 +76,6 @@ class Node extends React.Component { labelFontSize /= zoomScale; subLabelFontSize /= zoomScale; labelOffsetY /= zoomScale; - subLabelOffsetY /= zoomScale; } const className = classNames({ @@ -98,11 +96,11 @@ class Node extends React.Component { x={-nodeScale(scaleFactor * 0.5)} y={-nodeScale(scaleFactor * 0.5)} width={nodeScale(scaleFactor)} - height={nodeScale(scaleFactor) + subLabelOffsetY} + height={nodeScale(scaleFactor)} /> + width={nodeScale(scaleFactor * 4)}>
diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 7c4352679..d646a2c71 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -8,7 +8,7 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { const { hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, - searchNodeMatches = makeMap(), selectedNodeId } = this.props; + searchNodeMatches = makeMap(), searchQuery, selectedNodeId } = this.props; return ( @@ -16,7 +16,7 @@ class NodesChartEdges extends React.Component { const sourceSelected = selectedNodeId === edge.get('source'); const targetSelected = selectedNodeId === edge.get('target'); const blurred = hasSelectedNode && !sourceSelected && !targetSelected - || searchNodeMatches.size > 0 && !(searchNodeMatches.has(edge.get('source')) + || searchQuery && !(searchNodeMatches.has(edge.get('source')) && searchNodeMatches.has(edge.get('target'))); const focused = hasSelectedNode && (sourceSelected || targetSelected); @@ -42,10 +42,11 @@ class NodesChartEdges extends React.Component { function mapStateToProps(state) { const currentTopologyId = state.get('currentTopologyId'); return { - searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), hasSelectedNode: hasSelectedNodeFn(state), - selectedNodeId: state.get('selectedNodeId'), - highlightedEdgeIds: state.get('highlightedEdgeIds') + highlightedEdgeIds: state.get('highlightedEdgeIds'), + searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), + searchQuery: state.get('searchQuery'), + selectedNodeId: state.get('selectedNodeId') }; } diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index af8f5b748..1a2d8ca62 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -8,7 +8,8 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, - nodeScale, scale, searchNodeMatches = makeMap(), selectedMetric, selectedNodeScale, + nodeScale, scale, searchNodeMatches = makeMap(), searchQuery, + selectedMetric, selectedNodeScale, selectedNodeId, topCardNode } = this.props; const zoomScale = scale; @@ -21,7 +22,7 @@ class NodesChartNodes extends React.Component { || (adjacentNodes && adjacentNodes.includes(node.get('id'))))); const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused') - || searchNodeMatches.size > 0 && !searchNodeMatches.has(node.get('id'))); + || searchQuery && !searchNodeMatches.has(node.get('id'))); // make sure blurred nodes are in the background const sortNodes = node => { @@ -86,6 +87,7 @@ function mapStateToProps(state) { selectedMetric: state.get('selectedMetric'), selectedNodeId: state.get('selectedNodeId'), searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), + searchQuery: state.get('searchQuery'), topCardNode: state.get('nodeDetails').last() }; } diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index f45212947..39691b74e 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -4,20 +4,26 @@ import cx from 'classnames'; import _ from 'lodash'; import { blurSearch, doSearch, focusSearch } from '../actions/app-actions'; +import { slugify } from '../utils/string-utils'; import { isTopologyEmpty } from '../utils/topology-utils'; -const SEARCH_HINTS = [ - 'Try "db" or "app1" to search by node label or sublabel.', - 'Try "sublabel:my-host" to search by node sublabel.', - 'Try "label:my-node" to search by node name.', - 'Try "metadata:my-node" to search through all node metadata.', - 'Try "dockerenv:my-env-value" to search through all docker environment variables.', - 'Try "all:my-value" to search through all metdata and labels.' -]; +// dynamic hint based on node names +function getHint(nodes) { + let label = 'mycontainer'; + let metadataLabel = 'ip'; + let metadataValue = '172.12'; -// every minute different hint -function getHint() { - return SEARCH_HINTS[(new Date).getMinutes() % SEARCH_HINTS.length]; + const node = nodes.last(); + if (node) { + label = node.get('label'); + if (node.get('metadata')) { + const metadataField = node.get('metadata').first(); + metadataLabel = slugify(metadataField.get('label')).toLowerCase(); + metadataValue = metadataField.get('value').toLowerCase(); + } + } + + return `Try "${label}" or "${metadataLabel}:${metadataValue}".`; } class Search extends React.Component { @@ -84,7 +90,7 @@ class Search extends React.Component { Search
-
{getHint()}
+
{getHint(this.props.nodes)}
); @@ -93,6 +99,7 @@ class Search extends React.Component { export default connect( state => ({ + nodes: state.get('nodes'), isTopologyEmpty: isTopologyEmpty(state), searchFocused: state.get('searchFocused'), searchQuery: state.get('searchQuery'), diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 5f0f796af..e5b7ba26e 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -1,71 +1,67 @@ import { Map as makeMap } from 'immutable'; +import { slugify } from './string-utils'; + +// topolevel search fields const SEARCH_FIELDS = makeMap({ label: 'label', sublabel: 'label_minor' }); -// TODO make this dynamic based on topology -const SEARCH_TABLES = makeMap({ - dockerlabel: 'docker_label_', - dockerenv: 'docker_env_', -}); - const PREFIX_DELIMITER = ':'; -const PREFIX_ALL = 'all'; -const PREFIX_ALL_SHORT = 'a'; -const PREFIX_METADATA = 'metadata'; -const PREFIX_METADATA_SHORT = 'm'; -function findNodeMatch(nodeMatches, keyPath, text, query, label) { - const index = text.indexOf(query); - if (index > -1) { - nodeMatches = nodeMatches.setIn(keyPath, - {text, label, matches: [{start: index, length: query.length}]}); +function matchPrefix(label, prefix) { + if (label && prefix) { + return (new RegExp(prefix, 'i')).test(slugify(label)); + } + return false; +} + +function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { + if (!prefix || matchPrefix(label, prefix)) { + const queryRe = new RegExp(query, 'i'); + const matches = text.match(queryRe); + if (matches) { + const firstMatch = matches[0]; + const index = text.search(queryRe); + nodeMatches = nodeMatches.setIn(keyPath, + {text, label, matches: [{start: index, length: firstMatch.length}]}); + } } return nodeMatches; } -function searchTopology(nodes, searchFields, prefix, query) { +function searchTopology(nodes, prefix, query) { let nodeMatches = makeMap(); nodes.forEach((node, nodeId) => { // top level fields - searchFields.forEach((field, label) => { + SEARCH_FIELDS.forEach((field, label) => { const keyPath = [nodeId, label]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), query, label); + nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), + query, prefix, label); }); // metadata - if (node.get('metadata') && (prefix === PREFIX_METADATA || prefix === PREFIX_METADATA_SHORT - || prefix === PREFIX_ALL || prefix === PREFIX_ALL_SHORT)) { + if (node.get('metadata')) { node.get('metadata').forEach(field => { const keyPath = [nodeId, 'metadata', field.get('id')]; nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), - query, field.get('label')); + query, prefix, field.get('label')); }); } // tables (envvars and labels) const tables = node.get('tables'); if (tables) { - let searchTables; - if (prefix === PREFIX_ALL || prefix === PREFIX_ALL_SHORT) { - searchTables = SEARCH_TABLES; - } else if (prefix) { - searchTables = SEARCH_TABLES.filter((field, label) => prefix === label); - } - if (searchTables && searchTables.size > 0) { - searchTables.forEach((searchTable) => { - const table = tables.find(t => t.get('id') === searchTable); - if (table && table.get('rows')) { - table.get('rows').forEach(field => { - const keyPath = [nodeId, 'metadata', field.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), - query, field.get('label')); - }); - } - }); - } + tables.forEach((table) => { + if (table.get('rows')) { + table.get('rows').forEach(field => { + const keyPath = [nodeId, 'metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, prefix, field.get('label')); + }); + } + }); } }); return nodeMatches; @@ -82,15 +78,13 @@ export function updateNodeMatches(state) { if (query && (isPrefixQuery === isValidPrefixQuery)) { const prefix = isValidPrefixQuery ? prefixQuery[0] : null; - let searchFields = SEARCH_FIELDS; if (isPrefixQuery) { query = prefixQuery[1]; - searchFields = searchFields.filter((field, label) => label === prefix); } state.get('topologyUrlsById').forEach((url, topologyId) => { const topologyNodes = state.getIn(['nodesByTopology', topologyId]); if (topologyNodes) { - const nodeMatches = searchTopology(topologyNodes, searchFields, prefix, query); + const nodeMatches = searchTopology(topologyNodes, prefix, query); state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); } }); diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index 9b7c22df4..9490ce4b4 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -64,3 +64,8 @@ function makeFormatMetric(renderFn) { export const formatMetric = makeFormatMetric(renderHtml); export const formatMetricSvg = makeFormatMetric(renderSvg); export const formatDate = d3.time.format.iso; + +const CLEAN_LABEL_REGEX = /\W/g; +export function slugify(label) { + return label.replace(CLEAN_LABEL_REGEX, ''); +} From d1609658bfa4b637dc5a1cf26666c5f6db72e962 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 12:22:50 +0200 Subject: [PATCH 04/17] Apply search as filter --- client/app/scripts/actions/app-actions.js | 31 ++++++ .../app/scripts/charts/nodes-chart-edges.js | 5 +- .../app/scripts/charts/nodes-chart-nodes.js | 8 +- client/app/scripts/charts/nodes-chart.js | 19 ++-- client/app/scripts/components/app.js | 5 +- client/app/scripts/components/search-item.js | 28 ++++++ client/app/scripts/components/search.js | 43 ++++++--- client/app/scripts/constants/action-types.js | 3 + client/app/scripts/reducers/root.js | 19 +++- client/app/scripts/utils/search-utils.js | 63 ++++++++++--- client/app/styles/main.less | 94 ++++++++++--------- 11 files changed, 231 insertions(+), 87 deletions(-) create mode 100644 client/app/scripts/components/search-item.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index f87d25d01..949393517 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -4,6 +4,7 @@ import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { modulo } from '../utils/math-utils'; import { updateRoute } from '../utils/router-utils'; +import { parseQuery } from '../utils/search-utils'; import { bufferDeltaUpdate, resumeUpdate, resetUpdateBuffer } from '../utils/update-buffer-utils'; import { doControlRequest, getAllNodes, getNodesDelta, getNodeDetails, @@ -69,6 +70,20 @@ export function pinNextMetric(delta) { }; } +export function pinSearch(query) { + return { + type: ActionTypes.PIN_SEARCH, + query + }; +} + +export function unpinSearch(query) { + return { + type: ActionTypes.UNPIN_SEARCH, + query + }; +} + export function blurSearch() { return { type: ActionTypes.BLUR_SEARCH }; } @@ -301,6 +316,22 @@ export function focusSearch() { }; } +export function hitEnter() { + return (dispatch, getState) => { + const state = getState(); + // pin query based on current search field + if (state.get('searchFocused')) { + const query = state.get('searchQuery'); + if (query && parseQuery(query)) { + dispatch({ + type: ActionTypes.PIN_SEARCH, + query + }); + } + } + }; +} + export function hitEsc() { return (dispatch, getState) => { const state = getState(); diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index d646a2c71..b643d79fa 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -7,8 +7,9 @@ import EdgeContainer from './edge-container'; class NodesChartEdges extends React.Component { render() { - const { hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, - searchNodeMatches = makeMap(), searchQuery, selectedNodeId } = this.props; + const { hasSelectedNode, highlightedEdgeIds, layoutEdges, + layoutPrecision, searchNodeMatches = makeMap(), searchQuery, + selectedNodeId } = this.props; return ( diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 1a2d8ca62..1a7a6bc49 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -7,10 +7,10 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, - nodeScale, scale, searchNodeMatches = makeMap(), searchQuery, - selectedMetric, selectedNodeScale, - selectedNodeId, topCardNode } = this.props; + const { adjacentNodes, highlightedNodeIds, layoutNodes, + layoutPrecision, nodeScale, scale, searchNodeMatches = makeMap(), + searchQuery, selectedMetric, selectedNodeScale, selectedNodeId, + topCardNode } = this.props; const zoomScale = scale; diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 9a88e7847..59ed21351 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -198,17 +198,14 @@ class NodesChart extends React.Component { if (!edges.has(edgeId)) { const source = edge[0]; const target = edge[1]; - - if (!stateNodes.has(source) || !stateNodes.has(target)) { - log('Missing edge node', edge[0], edge[1]); + if (stateNodes.has(source) && stateNodes.has(target)) { + edges = edges.set(edgeId, makeMap({ + id: edgeId, + value: 1, + source, + target + })); } - - edges = edges.set(edgeId, makeMap({ - id: edgeId, - value: 1, - source, - target - })); } }); } @@ -404,7 +401,7 @@ function mapStateToProps(state) { return { adjacentNodes: getAdjacentNodes(state), forceRelayout: state.get('forceRelayout'), - nodes: state.get('nodes'), + nodes: state.get('nodes').filter(node => !node.get('filtered')), selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('topologyId'), topologyOptions: getActiveTopologyOptions(state) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index d50aaf601..271b07672 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -11,7 +11,7 @@ import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies } from '../utils/web-api-utils'; -import { pinNextMetric, hitEsc, unpinMetric, +import { pinNextMetric, hitEnter, hitEsc, unpinMetric, selectMetric, toggleHelp } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; @@ -23,6 +23,7 @@ import DebugToolbar, { showingDebugToolbar, import { getUrlState } from '../utils/router-utils'; import { getActiveTopologyOptions } from '../utils/topology-utils'; +const ENTER_KEY_CODE = 13; const ESC_KEY_CODE = 27; const keyPressLog = debug('scope:app-key-press'); @@ -55,6 +56,8 @@ class App extends React.Component { // don't get esc in onKeyPress if (ev.keyCode === ESC_KEY_CODE) { this.props.dispatch(hitEsc()); + } else if (ev.keyCode === ENTER_KEY_CODE) { + this.props.dispatch(hitEnter()); } } diff --git a/client/app/scripts/components/search-item.js b/client/app/scripts/components/search-item.js new file mode 100644 index 000000000..518ffa11c --- /dev/null +++ b/client/app/scripts/components/search-item.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { unpinSearch } from '../actions/app-actions'; + +class SearchItem extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClick = this.handleClick.bind(this); + } + + handleClick(ev) { + ev.preventDefault(); + this.props.unpinSearch(this.props.query); + } + + render() { + return ( + + {this.props.query} + + + ); + } +} + +export default connect(null, { unpinSearch })(SearchItem); diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index 39691b74e..e0bb7e3c3 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -6,6 +6,7 @@ import _ from 'lodash'; import { blurSearch, doSearch, focusSearch } from '../actions/app-actions'; import { slugify } from '../utils/string-utils'; import { isTopologyEmpty } from '../utils/topology-utils'; +import SearchItem from './search-item'; // dynamic hint based on node names function getHint(nodes) { @@ -18,12 +19,20 @@ function getHint(nodes) { label = node.get('label'); if (node.get('metadata')) { const metadataField = node.get('metadata').first(); - metadataLabel = slugify(metadataField.get('label')).toLowerCase(); - metadataValue = metadataField.get('value').toLowerCase(); + metadataLabel = slugify(metadataField.get('label')) + .toLowerCase() + .split(' ')[0] + .split('.').pop() + .substr(0, 20); + metadataValue = metadataField.get('value') + .toLowerCase() + .split(' ')[0] + .substr(0, 12); } } - return `Try "${label}" or "${metadataLabel}:${metadataValue}".`; + return `Try "${label}" or "${metadataLabel}:${metadataValue}". + Hit enter to apply the search as a filter.`; } class Search extends React.Component { @@ -65,14 +74,17 @@ class Search extends React.Component { } render() { - const inputId = this.props.inputId || 'search'; - const disabled = this.props.isTopologyEmpty || !this.props.topologiesLoaded; - const matchCount = this.props.searchNodeMatches + const { inputId = 'search', nodes, pinnedSearches, searchFocused, + searchNodeMatches, topologiesLoaded } = this.props; + const disabled = this.props.isTopologyEmpty || !topologiesLoaded; + const matchCount = searchNodeMatches .reduce((count, topologyMatches) => count + topologyMatches.size, 0); + const showPinnedSearches = pinnedSearches.size > 0; const classNames = cx('search', { + 'search-pinned': showPinnedSearches, 'search-matched': matchCount, 'search-filled': this.state.value, - 'search-focused': this.props.searchFocused, + 'search-focused': searchFocused, 'search-disabled': disabled }); const title = matchCount ? `${matchCount} matches` : null; @@ -81,16 +93,22 @@ class Search extends React.Component {
+ + + {showPinnedSearches && + {pinnedSearches.toIndexedSeq() + .map(query => )} + } -
-
{getHint(this.props.nodes)}
+ {!showPinnedSearches &&
+ {getHint(nodes)} +
}
); @@ -101,6 +119,7 @@ export default connect( state => ({ nodes: state.get('nodes'), isTopologyEmpty: isTopologyEmpty(state), + pinnedSearches: state.get('pinnedSearches'), searchFocused: state.get('searchFocused'), searchQuery: state.get('searchQuery'), searchNodeMatches: state.get('searchNodeMatches'), diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 731b675ba..18e42a69f 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -1,6 +1,7 @@ import _ from 'lodash'; const ACTION_TYPES = [ + 'ADD_QUERY_FILTER', 'BLUR_SEARCH', 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', @@ -28,7 +29,9 @@ const ACTION_TYPES = [ 'LEAVE_EDGE', 'LEAVE_NODE', 'PIN_METRIC', + 'PIN_SEARCH', 'UNPIN_METRIC', + 'UNPIN_SEARCH', 'OPEN_WEBSOCKET', 'RECEIVE_CONTROL_NODE_REMOVED', 'RECEIVE_CONTROL_PIPE', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index c785aaf75..062e92e89 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -5,7 +5,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, import ActionTypes from '../constants/action-types'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { updateNodeMatches } from '../utils/search-utils'; +import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils'; import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; @@ -55,6 +55,7 @@ export const initialState = makeMap({ // allows us to keep the same metric "type" selected when the topology changes. pinnedMetricType: null, plugins: makeList(), + pinnedSearches: makeList(), // list of node filters routeSet: false, searchFocused: false, searchNodeMatches: makeMap(), @@ -398,6 +399,13 @@ export function rootReducer(state = initialState, action) { return state.set('searchFocused', true); } + case ActionTypes.PIN_SEARCH: { + state = state.set('searchQuery', ''); + 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); } @@ -476,6 +484,9 @@ export function rootReducer(state = initialState, action) { state = state.setIn(['nodes', node.id], fromJS(makeNode(node))); }); + // apply pinned searches, filters nodes that dont match + state = applyPinnedSearches(state); + state = state.set('availableCanvasMetrics', state.get('nodes') .valueSeq() .flatMap(n => (n.get('metrics') || makeList()).map(m => ( @@ -578,6 +589,12 @@ export function rootReducer(state = initialState, action) { 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; } diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index e5b7ba26e..88f9b88bf 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -31,7 +31,7 @@ function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { return nodeMatches; } -function searchTopology(nodes, prefix, query) { +export function searchTopology(nodes, { prefix, query }) { let nodeMatches = makeMap(); nodes.forEach((node, nodeId) => { // top level fields @@ -67,24 +67,36 @@ function searchTopology(nodes, prefix, query) { return nodeMatches; } +export function parseQuery(query) { + if (query) { + const prefixQuery = query.split(PREFIX_DELIMITER); + const isPrefixQuery = prefixQuery && prefixQuery.length === 2; + const valid = !isPrefixQuery || prefixQuery.every(s => s); + if (valid) { + let prefix = null; + if (isPrefixQuery) { + prefix = prefixQuery[0]; + query = prefixQuery[1]; + } + return { + query, + prefix + }; + } + } + return null; +} + /** * Returns {topologyId: {nodeId: matches}} */ export function updateNodeMatches(state) { - let query = state.get('searchQuery'); - const prefixQuery = query && query.split(PREFIX_DELIMITER); - const isPrefixQuery = prefixQuery && prefixQuery.length === 2; - const isValidPrefixQuery = isPrefixQuery && prefixQuery.every(s => s); - - if (query && (isPrefixQuery === isValidPrefixQuery)) { - const prefix = isValidPrefixQuery ? prefixQuery[0] : null; - if (isPrefixQuery) { - query = prefixQuery[1]; - } + const parsed = parseQuery(state.get('searchQuery')); + if (parsed) { state.get('topologyUrlsById').forEach((url, topologyId) => { const topologyNodes = state.getIn(['nodesByTopology', topologyId]); if (topologyNodes) { - const nodeMatches = searchTopology(topologyNodes, prefix, query); + const nodeMatches = searchTopology(topologyNodes, parsed); state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); } }); @@ -94,3 +106,30 @@ export function updateNodeMatches(state) { return state; } + +/** + * Set `filtered:true` in state's nodes if a pinned search matches + */ +export function applyPinnedSearches(state) { + // clear old filter state + state = state.update('nodes', + nodes => nodes.map(node => node.set('filtered', false))); + + const pinnedSearches = state.get('pinnedSearches'); + if (pinnedSearches.size > 0) { + state.get('pinnedSearches').forEach(query => { + const parsed = parseQuery(query); + if (parsed) { + const nodeMatches = searchTopology(state.get('nodes'), parsed); + const filteredNodes = state.get('nodes') + .map(node => node.set('filtered', + node.get('filtered') // matched by previous pinned search + || nodeMatches.size === 0 // no match, filter all nodes + || !nodeMatches.has(node.get('id')))); // filter matches + state = state.set('nodes', filteredNodes); + } + }); + } + + return state; +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 43db6a9cc..b6ce58573 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -1213,7 +1213,7 @@ h2 { &-hint { font-size: 0.7rem; position: absolute; - padding: 0 1em 0 3em; + padding: 0 1em; color: @text-tertiary-color; top: 0; opacity: 0; @@ -1226,89 +1226,76 @@ h2 { background: #fff; position: relative; z-index: 1; - display: inline-block; + display: flex; border-radius: @border-radius; width: 100%; border: 1px solid transparent; + padding: 2px 4px; + text-align: left; + + &-items { + padding: 2px 4px; + } &-field { font-size: 0.8rem; line-height: 150%; position: relative; - display: block; - float: right; - padding: 4px 8px 4px 32px; - width: 100%; + padding: 1px 4px 1px 0.75em; border: none; border-radius: 0; background: transparent; color: @text-color; + flex: 1; &:focus { outline: none; } } + &-icon { + position: relative; + width: 1.285em; + text-align: center; + color: @text-secondary-color; + position: relative; + top: 4px; + left: 4px; + } + &-label { user-select: none; display: inline-block; - float: right; - padding: 0 0.75em; + padding: 2px 1em; font-size: 0.8rem; position: absolute; - top: -10px; - width: 100%; text-align: left; pointer-events: none; color: @text-secondary-color; - - &-icon { - top: 10px; - position: relative; - width: 1.285em; - text-align: center; - color: @text-secondary-color; - transition: opacity 0.3s 0.4s @base-ease; - opacity: 0; - display: inline-block; - } - - &-text { - color: @text-secondary-color; - text-align: left; - padding: 4px 0; - top: 10px; - position: relative; - left: -1.2em; - transition: opacity 0.3s 0.5s @base-ease; - opacity: 1; - display: inline-block; - text-transform: uppercase; - } + text-transform: uppercase; + transition: opacity 0.3s 0.5s @base-ease; + opacity: 1; } } - &-focused &-input-label-icon, - &-filled &-input-label-icon { - transition: opacity 0.3s 0s @base-ease; - opacity: 1; - } - - &-focused &-input-label-text, - &-filled &-input-label-text { + &-focused &-input-label, + &-pinned &-input-label, + &-filled &-input-label { transition: opacity 0.1s 0s @base-ease; opacity: 0; } &-focused &-hint, - &-filled &-hint { + &-filled &-hint, + &-pinned &-hint { opacity: 1; transform: translate3d(0, 2.75em, 0); transition: transform 0.3s 0.3s @base-ease, opacity 0.3s 0.3s @base-ease; } &-focused, - &-filled { + &-filled, + &-pinned { width: 100%; } @@ -1318,6 +1305,25 @@ h2 { } +.search-item { + background-color: fade(@weave-blue, 20%); + border-radius: @border-radius / 2; + margin-left: 4px; + + &-label { + padding: 2px 4px; + } + + &-icon { + .btn-opacity; + padding: 2px 4px 2px 2px; + cursor: pointer; + font-size: 80%; + position: relative; + top: -1px; + } +} + @keyframes focusing { 0% { opacity: 0; From 3e26ed708344207cebea8280e3b7a747e143419b Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 17:21:46 +0200 Subject: [PATCH 05/17] Metric matching support for search Examples: * cpu > 2 // means percent * memory > 4.5MB --- client/app/scripts/components/search.js | 1 - client/app/scripts/utils/search-utils.js | 168 ++++++++++++++++++----- client/app/scripts/utils/string-utils.js | 2 +- 3 files changed, 132 insertions(+), 39 deletions(-) diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index e0bb7e3c3..f35aba549 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -20,7 +20,6 @@ function getHint(nodes) { if (node.get('metadata')) { const metadataField = node.get('metadata').first(); metadataLabel = slugify(metadataField.get('label')) - .toLowerCase() .split(' ')[0] .split('.').pop() .substr(0, 20); diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 88f9b88bf..5da86bc79 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -1,4 +1,5 @@ import { Map as makeMap } from 'immutable'; +import _ from 'lodash'; import { slugify } from './string-utils'; @@ -8,8 +9,29 @@ const SEARCH_FIELDS = makeMap({ sublabel: 'label_minor' }); +const COMPARISONS = makeMap({ + '<': 'lt', + '>': 'gt', + '=': 'eq' +}); +const COMPARISONS_REGEX = new RegExp(`[${COMPARISONS.keySeq().toJS().join('')}]`); + const PREFIX_DELIMITER = ':'; +function parseValue(value) { + let parsed = parseFloat(value); + if (_.endsWith(value, 'KB')) { + parsed *= 1024; + } else if (_.endsWith(value, 'MB')) { + parsed *= 1024 * 1024; + } else if (_.endsWith(value, 'GB')) { + parsed *= 1024 * 1024 * 1024; + } else if (_.endsWith(value, 'TB')) { + parsed *= 1024 * 1024 * 1024 * 1024; + } + return parsed; +} + function matchPrefix(label, prefix) { if (label && prefix) { return (new RegExp(prefix, 'i')).test(slugify(label)); @@ -31,37 +53,86 @@ function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { return nodeMatches; } -export function searchTopology(nodes, { prefix, query }) { +/** + * If the metric matches the field's label and the value compares positively + * with the comp operator, a nodeMatch is added + */ +function findNodeMatchMetric(nodeMatches, keyPath, fieldValue, fieldLabel, metric, comp, value) { + if (slugify(metric) === slugify(fieldLabel)) { + let matched = false; + switch (comp) { + case 'gt': { + if (fieldValue > value) { + matched = true; + } + break; + } + case 'lt': { + if (fieldValue < value) { + matched = true; + } + break; + } + case 'eq': { + if (fieldValue === value) { + matched = true; + } + break; + } + default: { + break; + } + } + if (matched) { + nodeMatches = nodeMatches.setIn(keyPath, + {fieldLabel, metric: true}); + } + } + return nodeMatches; +} + +export function searchTopology(nodes, { prefix, query, metric, comp, value }) { let nodeMatches = makeMap(); nodes.forEach((node, nodeId) => { - // top level fields - SEARCH_FIELDS.forEach((field, label) => { - const keyPath = [nodeId, label]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), - query, prefix, label); - }); - - // metadata - if (node.get('metadata')) { - node.get('metadata').forEach(field => { - const keyPath = [nodeId, 'metadata', field.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), - query, prefix, field.get('label')); + if (query) { + // top level fields + SEARCH_FIELDS.forEach((field, label) => { + const keyPath = [nodeId, label]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), + query, prefix, label); }); - } - // tables (envvars and labels) - const tables = node.get('tables'); - if (tables) { - tables.forEach((table) => { - if (table.get('rows')) { - table.get('rows').forEach(field => { - const keyPath = [nodeId, 'metadata', field.get('id')]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), - query, prefix, field.get('label')); - }); - } - }); + // metadata + if (node.get('metadata')) { + node.get('metadata').forEach(field => { + const keyPath = [nodeId, 'metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, prefix, field.get('label')); + }); + } + + // tables (envvars and labels) + const tables = node.get('tables'); + if (tables) { + tables.forEach((table) => { + if (table.get('rows')) { + table.get('rows').forEach(field => { + const keyPath = [nodeId, 'metadata', field.get('id')]; + nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), + query, prefix, field.get('label')); + }); + } + }); + } + } else if (metric) { + const metrics = node.get('metrics'); + if (metrics) { + metrics.forEach(field => { + const keyPath = [nodeId, 'metrics', field.get('id')]; + nodeMatches = findNodeMatchMetric(nodeMatches, keyPath, field.get('value'), + field.get('label'), metric, comp, value); + }); + } } }); return nodeMatches; @@ -71,17 +142,40 @@ export function parseQuery(query) { if (query) { const prefixQuery = query.split(PREFIX_DELIMITER); const isPrefixQuery = prefixQuery && prefixQuery.length === 2; - const valid = !isPrefixQuery || prefixQuery.every(s => s); - if (valid) { - let prefix = null; - if (isPrefixQuery) { - prefix = prefixQuery[0]; - query = prefixQuery[1]; + + if (isPrefixQuery) { + const prefix = prefixQuery[0].trim(); + query = prefixQuery[1].trim(); + if (prefix && query) { + return { + query, + prefix + }; } - return { - query, - prefix - }; + } else if (COMPARISONS_REGEX.test(query)) { + // check for comparisons + let comparison; + COMPARISONS.forEach((comp, delim) => { + const comparisonQuery = query.split(delim); + if (comparisonQuery && comparisonQuery.length === 2) { + const value = parseValue(comparisonQuery[1]); + const metric = comparisonQuery[0].trim(); + if (!isNaN(value) && metric) { + comparison = { + metric, + value, + comp + }; + return false; // dont look further + } + } + return true; + }); + if (comparison) { + return comparison; + } + } else { + return { query }; } } return null; diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index 9490ce4b4..5f8d180d5 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -67,5 +67,5 @@ export const formatDate = d3.time.format.iso; const CLEAN_LABEL_REGEX = /\W/g; export function slugify(label) { - return label.replace(CLEAN_LABEL_REGEX, ''); + return label.replace(CLEAN_LABEL_REGEX, '').toLowerCase(); } From 6ca729a6c2e8104fad92dc218edb0a60c48a1f5a Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 17:35:55 +0200 Subject: [PATCH 06/17] Fix tests by not mocking dependent utils --- .../app/scripts/reducers/__tests__/root-test.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index 506d17d8a..cfde7428e 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -1,4 +1,6 @@ jest.dontMock('../../utils/router-utils'); +jest.dontMock('../../utils/search-utils'); +jest.dontMock('../../utils/string-utils'); jest.dontMock('../../utils/topology-utils'); jest.dontMock('../../constants/action-types'); jest.dontMock('../root'); @@ -27,7 +29,12 @@ describe('RootReducer', () => { adjacency: ['n1', 'n2'], pseudo: undefined, label: undefined, - label_minor: undefined + label_minor: undefined, + filtered: false, + metrics: undefined, + node_count: undefined, + shape: undefined, + stack: undefined }, n2: { id: 'n2', @@ -35,7 +42,12 @@ describe('RootReducer', () => { adjacency: undefined, pseudo: undefined, label: undefined, - label_minor: undefined + label_minor: undefined, + filtered: false, + metrics: undefined, + node_count: undefined, + shape: undefined, + stack: undefined } }; From d5eea2549d1d44fff45390cbfd8d9518049bb681 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 17:59:21 +0200 Subject: [PATCH 07/17] Sanitize inputs Try regexp, escape if invalid --- client/app/scripts/utils/search-utils.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 5da86bc79..4ec786dd3 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -18,6 +18,14 @@ const COMPARISONS_REGEX = new RegExp(`[${COMPARISONS.keySeq().toJS().join('')}]` const PREFIX_DELIMITER = ':'; +function makeRegExp(expression, options = 'i') { + try { + return new RegExp(expression, options); + } catch (e) { + return new RegExp(_.escapeRegExp(expression), options); + } +} + function parseValue(value) { let parsed = parseFloat(value); if (_.endsWith(value, 'KB')) { @@ -34,14 +42,14 @@ function parseValue(value) { function matchPrefix(label, prefix) { if (label && prefix) { - return (new RegExp(prefix, 'i')).test(slugify(label)); + return (makeRegExp(prefix)).test(slugify(label)); } return false; } function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { if (!prefix || matchPrefix(label, prefix)) { - const queryRe = new RegExp(query, 'i'); + const queryRe = makeRegExp(query); const matches = text.match(queryRe); if (matches) { const firstMatch = matches[0]; From cfb5161cd7ff7f6a7deb05e403e1217f101360c8 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 18:35:45 +0200 Subject: [PATCH 08/17] Store search state in URL --- client/app/scripts/actions/app-actions.js | 26 +++++++++++------------ client/app/scripts/components/search.js | 7 +++--- client/app/scripts/reducers/root.js | 3 +++ client/app/scripts/utils/router-utils.js | 4 +++- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 949393517..3b05f13b3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -70,17 +70,13 @@ export function pinNextMetric(delta) { }; } -export function pinSearch(query) { - return { - type: ActionTypes.PIN_SEARCH, - query - }; -} - export function unpinSearch(query) { - return { - type: ActionTypes.UNPIN_SEARCH, - query + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.UNPIN_SEARCH, + query + }); + updateRoute(getState); }; } @@ -285,9 +281,12 @@ export function doControl(nodeId, control) { } export function doSearch(searchQuery) { - return { - type: ActionTypes.DO_SEARCH, - searchQuery + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.DO_SEARCH, + searchQuery + }); + updateRoute(getState); }; } @@ -327,6 +326,7 @@ export function hitEnter() { type: ActionTypes.PIN_SEARCH, query }); + updateRoute(getState); } } }; diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index f35aba549..832dd9352 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -74,15 +74,16 @@ class Search extends React.Component { render() { const { inputId = 'search', nodes, pinnedSearches, searchFocused, - searchNodeMatches, topologiesLoaded } = this.props; + searchNodeMatches, searchQuery, topologiesLoaded } = this.props; const disabled = this.props.isTopologyEmpty || !topologiesLoaded; const matchCount = searchNodeMatches .reduce((count, topologyMatches) => count + topologyMatches.size, 0); const showPinnedSearches = pinnedSearches.size > 0; + const value = this.state.value || searchQuery || ''; const classNames = cx('search', { 'search-pinned': showPinnedSearches, 'search-matched': matchCount, - 'search-filled': this.state.value, + 'search-filled': value, 'search-focused': searchFocused, 'search-disabled': disabled }); @@ -101,7 +102,7 @@ class Search extends React.Component { .map(query => )}
} diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 062e92e89..2a7eb1a71 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -401,6 +401,7 @@ export function rootReducer(state = initialState, action) { 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); @@ -557,6 +558,8 @@ export function rootReducer(state = initialState, action) { 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()); } diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 85cfc7d1f..ffe42c164 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -35,8 +35,10 @@ export function getUrlState(state) { return { controlPipe: cp ? cp.toJS() : null, nodeDetails: nodeDetails.toJS(), - selectedNodeId: state.get('selectedNodeId'), pinnedMetricType: state.get('pinnedMetricType'), + pinnedSearches: state.get('pinnedSearches').toJS(), + searchQuery: state.get('searchQuery'), + selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('currentTopologyId'), topologyOptions: state.get('topologyOptions').toJS() // all options }; From 749571ebe998f75334ac189e27bf8898d26e457d Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 4 May 2016 20:09:53 +0200 Subject: [PATCH 09/17] Review feedback * Fix node-details-test for search * Label spacing and matched text truncation * Delete pinned search on backspace, add hint for metrics, escape % in URL * Fix text-bg on node highlight * Added tests for search-utils * Fix matching of other topologies, added comment re quick clear * s/cx/classnames/ * Ignore MoC keys when search in focus, blur on Esc * Fixes search term highlighting on-hover * Fix SVG exports * Fine-tuned search item rendering * Fixed search highlighting in the details panel * Dont throb node on hover * Hotkey for search: '/' * Keep focus on search when tabbing away from the browser * bring hovered node to top * background for search results on hover * fixed height for foreign object to prevent layout glitches * Dont blur focused nodes on search * More robust metric matchers * More meaningful search hints --- client/app/scripts/actions/app-actions.js | 28 +- client/app/scripts/charts/node.js | 109 ++++--- .../app/scripts/charts/nodes-chart-nodes.js | 10 +- client/app/scripts/charts/nodes-layout.js | 2 +- .../components/__tests__/node-details-test.js | 2 +- client/app/scripts/components/app.js | 42 ++- client/app/scripts/components/footer.js | 2 +- client/app/scripts/components/help-panel.js | 2 +- .../app/scripts/components/matched-results.js | 11 +- client/app/scripts/components/matched-text.js | 107 +++++-- client/app/scripts/components/node-details.js | 2 +- .../node-details/node-details-info.js | 5 +- .../node-details/node-details-labels.js | 2 +- client/app/scripts/components/search.js | 74 +++-- client/app/scripts/components/topologies.js | 6 +- client/app/scripts/reducers/root.js | 7 +- .../utils/__tests__/search-utils-test.js | 300 ++++++++++++++++++ client/app/scripts/utils/router-utils.js | 11 +- client/app/scripts/utils/search-utils.js | 71 ++++- client/app/styles/contrast.less | 3 + client/app/styles/main.less | 78 +++-- 21 files changed, 676 insertions(+), 198 deletions(-) create mode 100644 client/app/scripts/utils/__tests__/search-utils-test.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 3b05f13b3..85a6246eb 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -143,7 +143,9 @@ export function clickCloseTerminal(pipeId, closePipe) { } export function clickDownloadGraph() { - saveGraph(); + return () => { + saveGraph(); + }; } export function clickForceRelayout() { @@ -315,6 +317,23 @@ export function focusSearch() { }; } +export function hitBackspace() { + return (dispatch, getState) => { + const state = getState(); + // remove last pinned query if search query is empty + if (state.get('searchFocused') && !state.get('searchQuery')) { + const query = state.get('pinnedSearches').last(); + if (query) { + dispatch({ + type: ActionTypes.UNPIN_SEARCH, + query + }); + updateRoute(getState); + } + } + }; +} + export function hitEnter() { return (dispatch, getState) => { const state = getState(); @@ -340,6 +359,8 @@ export function hitEsc() { dispatch(hideHelp()); } else if (state.get('searchQuery')) { dispatch(doSearch('')); + } else if (state.get('searchFocused')) { + dispatch(blurSearch()); } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { dispatch({ type: ActionTypes.CLICK_CLOSE_TERMINAL, @@ -416,6 +437,7 @@ export function receiveNodesForTopology(nodes, topologyId) { export function receiveTopologies(topologies) { return (dispatch, getState) => { + const firstLoad = !getState().get('topologiesLoaded'); dispatch({ type: ActionTypes.RECEIVE_TOPOLOGIES, topologies @@ -431,6 +453,10 @@ export function receiveTopologies(topologies) { state.get('nodeDetails'), dispatch ); + // populate search matches on first load + if (firstLoad && state.get('searchQuery')) { + dispatch(focusSearch()); + } }; } diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 1c8ecda43..76c517b7d 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,7 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { connect } from 'react-redux'; -import classNames from 'classnames'; +import classnames from 'classnames'; +import { Map as makeMap } from 'immutable'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; import { getNodeColor } from '../utils/color-utils'; @@ -36,16 +37,6 @@ function getNodeShape({ shape, stack }) { return stack ? stackedShape(nodeShape) : nodeShape; } -function ellipsis(text, fontSize, maxWidth) { - const averageCharLength = fontSize / 1.5; - const allowedChars = maxWidth / averageCharLength; - let truncatedText = text; - if (text && text.length > allowedChars) { - truncatedText = `${text.slice(0, allowedChars)}...`; - } - return truncatedText; -} - class Node extends React.Component { constructor(props, context) { @@ -53,66 +44,80 @@ class Node extends React.Component { this.handleMouseClick = this.handleMouseClick.bind(this); this.handleMouseEnter = this.handleMouseEnter.bind(this); this.handleMouseLeave = this.handleMouseLeave.bind(this); - this.state = { hovered: false }; + this.state = { + hovered: false, + matched: false + }; + } + + componentWillReceiveProps(nextProps) { + // marks as matched only when search query changes + if (nextProps.searchQuery !== this.props.searchQuery) { + this.setState({ + matched: nextProps.matched + }); + } else { + this.setState({ + matched: false + }); + } } render() { - const { blurred, focused, highlighted, label, matched, matches, pseudo, rank, - subLabel, scaleFactor, transform, zoomScale } = this.props; - const { hovered } = this.state; + const { blurred, focused, highlighted, label, matches = makeMap(), + pseudo, rank, subLabel, scaleFactor, transform, zoomScale } = this.props; + const { hovered, matched } = this.state; const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale; const color = getNodeColor(rank, label, pseudo); const truncate = !focused && !hovered; - const labelText = truncate ? ellipsis(label, 14, nodeScale(4 * scaleFactor)) : label; - const subLabelText = truncate ? ellipsis(subLabel, 12, nodeScale(4 * scaleFactor)) : subLabel; + const labelTransform = focused ? `scale(${1 / zoomScale})` : ''; + const labelWidth = nodeScale(scaleFactor * 4); + const labelOffsetX = -labelWidth / 2; + const labelOffsetY = focused ? nodeScale(0.5) : nodeScale(0.5 * scaleFactor); - let labelOffsetY = 8; - let labelFontSize = 14; - let subLabelFontSize = 12; - - // render focused nodes in normal size - if (focused) { - labelFontSize /= zoomScale; - subLabelFontSize /= zoomScale; - labelOffsetY /= zoomScale; - } - - const className = classNames({ - node: true, + const nodeClassName = classnames('node', { highlighted, - blurred, + blurred: blurred && !focused, hovered, matched, pseudo }); + const labelClassName = classnames('node-label', { truncate }); + const subLabelClassName = classnames('node-sublabel', { truncate }); + const NodeShapeType = getNodeShape(this.props); return ( - - - -
- + {/* For browser */} + +
+
+ +
+
+ +
+ {!blurred && }
-
- -
-
- + {/* For SVG export */} + + {label} + + {subLabel} + + + + + ); } @@ -135,6 +140,6 @@ class Node extends React.Component { } export default connect( - null, + state => ({ searchQuery: state.get('searchQuery') }), { clickNode, enterNode, leaveNode } )(Node); diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 1a7a6bc49..878e18206 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -7,8 +7,8 @@ import NodeContainer from './node-container'; class NodesChartNodes extends React.Component { render() { - const { adjacentNodes, highlightedNodeIds, layoutNodes, - layoutPrecision, nodeScale, scale, searchNodeMatches = makeMap(), + const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, + mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(), searchQuery, selectedMetric, selectedNodeScale, selectedNodeId, topCardNode } = this.props; @@ -26,7 +26,10 @@ class NodesChartNodes extends React.Component { // make sure blurred nodes are in the background const sortNodes = node => { - if (node.get('blurred')) { + if (node.get('id') === mouseOverNodeId) { + return 3; + } + if (node.get('blurred') && !node.get('focused')) { return 0; } if (node.get('highlighted')) { @@ -84,6 +87,7 @@ function mapStateToProps(state) { return { adjacentNodes: getAdjacentNodes(state), highlightedNodeIds: state.get('highlightedNodeIds'), + mouseOverNodeId: state.get('mouseOverNodeId'), selectedMetric: state.get('selectedMetric'), selectedNodeId: state.get('selectedNodeId'), searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 24503f496..f700dd477 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -12,7 +12,7 @@ const DEFAULT_WIDTH = 800; const DEFAULT_MARGINS = {top: 0, left: 0}; const DEFAULT_SCALE = val => val * 2; const NODE_SIZE_FACTOR = 1; -const NODE_SEPARATION_FACTOR = 2.5; +const NODE_SEPARATION_FACTOR = 3.0; const RANK_SEPARATION_FACTOR = 2.5; let layoutRuns = 0; let layoutRunsTrivial = 0; diff --git a/client/app/scripts/components/__tests__/node-details-test.js b/client/app/scripts/components/__tests__/node-details-test.js index e6be1701c..fa97047d3 100644 --- a/client/app/scripts/components/__tests__/node-details-test.js +++ b/client/app/scripts/components/__tests__/node-details-test.js @@ -39,6 +39,6 @@ describe('NodeDetails', () => { nodeId={nodeId} details={details} />); const title = TestUtils.findRenderedDOMComponentWithClass(c, 'node-details-header-label'); - expect(title.textContent).toBe('Node 1'); + expect(title.title).toBe('Node 1'); }); }); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 271b07672..32bb23cf7 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -11,7 +11,7 @@ import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies } from '../utils/web-api-utils'; -import { pinNextMetric, hitEnter, hitEsc, unpinMetric, +import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric, selectMetric, toggleHelp } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; @@ -23,6 +23,7 @@ import DebugToolbar, { showingDebugToolbar, import { getUrlState } from '../utils/router-utils'; import { getActiveTopologyOptions } from '../utils/topology-utils'; +const BACKSPACE_KEY_CODE = 8; const ENTER_KEY_CODE = 13; const ESC_KEY_CODE = 27; const keyPressLog = debug('scope:app-key-press'); @@ -58,31 +59,38 @@ class App extends React.Component { this.props.dispatch(hitEsc()); } else if (ev.keyCode === ENTER_KEY_CODE) { this.props.dispatch(hitEnter()); + } else if (ev.keyCode === BACKSPACE_KEY_CODE) { + this.props.dispatch(hitBackspace()); + } else if (ev.code === 'KeyD' && ev.ctrlKey) { + toggleDebugToolbar(); + this.forceUpdate(); } } onKeyPress(ev) { - const { dispatch } = this.props; + const { dispatch, searchFocused } = this.props; // // keyup gives 'key' // keypress gives 'char' // Distinction is important for international keyboard layouts where there // is often a different {key: char} mapping. // - keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev); - const char = String.fromCharCode(ev.charCode); - if (char === '<') { - dispatch(pinNextMetric(-1)); - } else if (char === '>') { - dispatch(pinNextMetric(1)); - } else if (char === 'q') { - dispatch(unpinMetric()); - dispatch(selectMetric(null)); - } else if (ev.code === 'KeyD' && ev.ctrlKey) { - toggleDebugToolbar(); - this.forceUpdate(); - } else if (char === '?') { - dispatch(toggleHelp()); + if (!searchFocused) { + keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev); + const char = String.fromCharCode(ev.charCode); + if (char === '<') { + dispatch(pinNextMetric(-1)); + } else if (char === '>') { + dispatch(pinNextMetric(1)); + } else if (char === 'q') { + dispatch(unpinMetric()); + dispatch(selectMetric(null)); + } else if (char === '/') { + ev.preventDefault(); + dispatch(focusSearch()); + } else if (char === '?') { + dispatch(toggleHelp()); + } } } @@ -133,6 +141,8 @@ function mapStateToProps(state) { controlPipes: state.get('controlPipes'), nodeDetails: state.get('nodeDetails'), routeSet: state.get('routeSet'), + searchFocused: state.get('searchFocused'), + searchQuery: state.get('searchQuery'), showingHelp: state.get('showingHelp'), urlState: getUrlState(state) }; diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index 6b5b37aee..fe9a45ed4 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -72,7 +72,7 @@ class Footer extends React.Component { + title="Save canvas as SVG (does not include search highlighting)"> diff --git a/client/app/scripts/components/help-panel.js b/client/app/scripts/components/help-panel.js index 42564a249..9df27eb92 100644 --- a/client/app/scripts/components/help-panel.js +++ b/client/app/scripts/components/help-panel.js @@ -2,6 +2,7 @@ import React from 'react'; const GENERAL_SHORTCUTS = [ {key: 'esc', label: 'Close active panel'}, + {key: '/', label: 'Activate search field'}, {key: '?', label: 'Toggle shortcut menu'}, ]; @@ -41,4 +42,3 @@ export default class HelpPanel extends React.Component { ); } } - diff --git a/client/app/scripts/components/matched-results.js b/client/app/scripts/components/matched-results.js index d860a7e6f..e044b69f5 100644 --- a/client/app/scripts/components/matched-results.js +++ b/client/app/scripts/components/matched-results.js @@ -3,19 +3,22 @@ import { connect } from 'react-redux'; import MatchedText from './matched-text'; -const SHOW_ROW_COUNT = 3; +const SHOW_ROW_COUNT = 2; +const MAX_MATCH_LENGTH = 24; class MatchedResults extends React.Component { renderMatch(matches, field) { const match = matches.get(field); + const text = match.text; + return (
{match.label}: - +
); @@ -41,9 +44,9 @@ class MatchedResults extends React.Component { return (
{matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))} - {moreFieldMatches && + {moreFieldMatches &&
{`${moreFieldMatches.size} more matches`} - } +
}
); } diff --git a/client/app/scripts/components/matched-text.js b/client/app/scripts/components/matched-text.js index a981a992b..2b00f29b9 100644 --- a/client/app/scripts/components/matched-text.js +++ b/client/app/scripts/components/matched-text.js @@ -1,67 +1,104 @@ import React from 'react'; import { connect } from 'react-redux'; +const TRUNCATE_CONTEXT = 6; +const TRUNCATE_ELLIPSIS = '…'; + /** * Returns an array with chunks that cover the whole text via {start, length} * objects. * - * `([{start: 2, length: 1}], "text") => - * [{start: 0, length: 2}, {start: 2, length: 1, match: true}, {start: 3, length: 1}]` + * `('text', {start: 2, length: 1}) => [{text: 'te'}, {text: 'x', match: true}, {text: 't'}]` */ -function reduceMatchesToChunks(matches, text) { - if (text && matches && matches.length > 0) { - const result = matches.reduce((chunks, match) => { - const prev = chunks.length > 0 ? chunks[chunks.length - 1] : null; - const end = prev ? prev.start + prev.length : 0; - // skip non-matching chunk if first chunk is match - if (match.start > 0) { - chunks.push({start: end, length: match.start}); - } - chunks.push(Object.assign({match: true}, match)); - return chunks; - }, []); - const last = result[result.length - 1]; - const remaining = last.start + last.length; - if (text && remaining < text.length) { - result.push({start: remaining, length: text.length - remaining}); +function chunkText(text, { start, length }) { + if (text && !isNaN(start) && !isNaN(length)) { + const chunks = []; + // text chunk before match + if (start > 0) { + chunks.push({text: text.substr(0, start)}); } - return result; + // matching chunk + chunks.push({match: true, text: text.substr(start, length)}); + // text after match + const remaining = start + length; + if (remaining < text.length) { + chunks.push({text: text.substr(remaining)}); + } + return chunks; } - return []; + return [{ text }]; } /** - * Renders text with highlighted search matches. + * Truncates chunks with ellipsis * - * `props.matches` must be an immutable.Map of match - * objects, the match object for this component will be extracted - * via `get(props.fieldId)`). - * A match object is of shape `{text, label, matches}`. - * `match.matches` is an array of text matches of shape `{start, length}` + * First chunk is truncated from left, second chunk (match) is truncated in the + * middle, last chunk is truncated at the end, e.g. + * `[{text: "...cation is a "}, {text: "useful...or not"}, {text: "tool..."}]` + */ +function truncateChunks(chunks, text, maxLength) { + if (chunks && chunks.length === 3 && maxLength && text && text.length > maxLength) { + const res = chunks.map(c => Object.assign({}, c)); + let needToCut = text.length - maxLength; + // trucate end + const end = res[2]; + if (end.text.length > TRUNCATE_CONTEXT) { + needToCut -= end.text.length - TRUNCATE_CONTEXT; + end.text = `${end.text.substr(0, TRUNCATE_CONTEXT)}${TRUNCATE_ELLIPSIS}`; + } + + if (needToCut) { + // truncate front + const start = res[0]; + if (start.text.length > TRUNCATE_CONTEXT) { + needToCut -= start.text.length - TRUNCATE_CONTEXT; + start.text = `${TRUNCATE_ELLIPSIS}` + + `${start.text.substr(start.text.length - TRUNCATE_CONTEXT)}`; + } + } + + if (needToCut) { + // truncate match + const middle = res[1]; + if (middle.text.length > 2 * TRUNCATE_CONTEXT) { + middle.text = `${middle.text.substr(0, TRUNCATE_CONTEXT)}` + + `${TRUNCATE_ELLIPSIS}` + + `${middle.text.substr(middle.text.length - TRUNCATE_CONTEXT)}`; + } + } + + return res; + } + return chunks; +} + +/** + * Renders text with highlighted search match. + * + * A match object is of shape `{text, label, match}`. + * `match` is a text match object of shape `{start, length}` * that delimit text matches in `text`. `label` shows the origin of the text. */ class MatchedText extends React.Component { render() { - const { fieldId, matches, text } = this.props; - // match is a direct match object, or still need to extract the correct field - const fieldMatches = matches && matches.get(fieldId); + const { match, text, maxLength } = this.props; - if (!fieldMatches) { + if (!match) { return {text}; } return ( - - {reduceMatchesToChunks(fieldMatches.matches, text).map((chunk, index) => { + + {truncateChunks(chunkText(text, match), text, maxLength).map((chunk, index) => { if (chunk.match) { return ( - - {text.substr(chunk.start, chunk.length)} + + {chunk.text} ); } - return text.substr(chunk.start, chunk.length); + return chunk.text; })} ); diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 6ca113c6a..c0e8363c2 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -162,7 +162,7 @@ export class NodeDetails extends React.Component {

- +

{details.parents && } diff --git a/client/app/scripts/components/node-details/node-details-info.js b/client/app/scripts/components/node-details/node-details-info.js index b41e850dd..5804c0904 100644 --- a/client/app/scripts/components/node-details/node-details-info.js +++ b/client/app/scripts/components/node-details/node-details-info.js @@ -1,4 +1,5 @@ import React from 'react'; +import { Map as makeMap } from 'immutable'; import MatchedText from '../matched-text'; import ShowMore from '../show-more'; @@ -19,7 +20,7 @@ export default class NodeDetailsInfo extends React.Component { } render() { - const { matches } = this.props; + const { matches = makeMap() } = this.props; let rows = (this.props.rows || []); let notShown = 0; @@ -41,7 +42,7 @@ export default class NodeDetailsInfo extends React.Component { {field.label}
- +
))} diff --git a/client/app/scripts/components/node-details/node-details-labels.js b/client/app/scripts/components/node-details/node-details-labels.js index 198aa49d2..1832395cd 100644 --- a/client/app/scripts/components/node-details/node-details-labels.js +++ b/client/app/scripts/components/node-details/node-details-labels.js @@ -43,7 +43,7 @@ export default class NodeDetailsLabels extends React.Component { {field.label}
- +
))} diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index 832dd9352..688325946 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -1,6 +1,7 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import { connect } from 'react-redux'; -import cx from 'classnames'; +import classnames from 'classnames'; import _ from 'lodash'; import { blurSearch, doSearch, focusSearch } from '../actions/app-actions'; @@ -8,29 +9,32 @@ import { slugify } from '../utils/string-utils'; import { isTopologyEmpty } from '../utils/topology-utils'; import SearchItem from './search-item'; +function shortenHintLabel(text) { + return text + .split(' ')[0] + .toLowerCase() + .substr(0, 12); +} + // dynamic hint based on node names function getHint(nodes) { let label = 'mycontainer'; let metadataLabel = 'ip'; - let metadataValue = '172.12'; + let metadataValue = '10.1.0.1'; - const node = nodes.last(); + const node = nodes.filter(n => !n.get('pseudo') && n.has('metadata')).last(); if (node) { - label = node.get('label'); + label = shortenHintLabel(node.get('label')) + .split('.')[0]; if (node.get('metadata')) { const metadataField = node.get('metadata').first(); - metadataLabel = slugify(metadataField.get('label')) - .split(' ')[0] - .split('.').pop() - .substr(0, 20); - metadataValue = metadataField.get('value') - .toLowerCase() - .split(' ')[0] - .substr(0, 12); + metadataLabel = shortenHintLabel(slugify(metadataField.get('label'))) + .split('.').pop(); + metadataValue = shortenHintLabel(metadataField.get('value')); } } - return `Try "${label}" or "${metadataLabel}:${metadataValue}". + return `Try "${label}", "${metadataLabel}:${metadataValue}", or "cpu > 2%". Hit enter to apply the search as a filter.`; } @@ -38,7 +42,6 @@ class Search extends React.Component { constructor(props, context) { super(props, context); - this.handleBlur = this.handleBlur.bind(this); this.handleChange = this.handleChange.bind(this); this.handleFocus = this.handleFocus.bind(this); this.doSearch = _.debounce(this.doSearch.bind(this), 200); @@ -47,14 +50,19 @@ class Search extends React.Component { }; } - handleBlur() { - this.props.blurSearch(); - } - handleChange(ev) { - const value = ev.target.value; + const inputValue = ev.target.value; + let value = inputValue; + // In render() props.searchQuery can be set from the outside, but state.value + // must have precendence for quick feedback. Now when the user backspaces + // quickly enough from `text`, a previouse doSearch(`text`) will come back + // via props and override the empty state.value. To detect this edge case + // we instead set value to null when backspacing. + if (this.state.value && value === '') { + value = null; + } this.setState({value}); - this.doSearch(value); + this.doSearch(inputValue); } handleFocus() { @@ -72,15 +80,25 @@ class Search extends React.Component { } } + componentDidUpdate() { + if (this.props.searchFocused) { + ReactDOM.findDOMNode(this.refs.queryInput).focus(); + } else if (!this.state.value) { + ReactDOM.findDOMNode(this.refs.queryInput).blur(); + } + } + render() { const { inputId = 'search', nodes, pinnedSearches, searchFocused, searchNodeMatches, searchQuery, topologiesLoaded } = this.props; - const disabled = this.props.isTopologyEmpty || !topologiesLoaded; + const disabled = this.props.isTopologyEmpty; const matchCount = searchNodeMatches .reduce((count, topologyMatches) => count + topologyMatches.size, 0); const showPinnedSearches = pinnedSearches.size > 0; - const value = this.state.value || searchQuery || ''; - const classNames = cx('search', { + // manual clear (null) has priority, then props, then state + const value = this.state.value === null ? '' : this.state.value || searchQuery || ''; + const classNames = classnames('search', 'hideable', { + hide: !topologiesLoaded, 'search-pinned': showPinnedSearches, 'search-matched': matchCount, 'search-filled': value, @@ -97,14 +115,12 @@ class Search extends React.Component { - {showPinnedSearches && - {pinnedSearches.toIndexedSeq() - .map(query => )} - } + {showPinnedSearches && pinnedSearches.toIndexedSeq() + .map(query => )} + onFocus={this.handleFocus} + disabled={disabled} ref="queryInput" /> {!showPinnedSearches &&
{getHint(nodes)} diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js index ea631ba63..8ffcc77aa 100644 --- a/client/app/scripts/components/topologies.js +++ b/client/app/scripts/components/topologies.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import cx from 'classnames'; +import classnames from 'classnames'; import { clickTopology } from '../actions/app-actions'; @@ -22,7 +22,7 @@ class Topologies extends React.Component { const searchMatches = this.props.searchNodeMatches.get(subTopology.get('id')); const searchMatchCount = searchMatches ? searchMatches.size : 0; const title = this.renderTitle(subTopology, searchMatchCount); - const className = cx('topologies-sub-item', { + const className = classnames('topologies-sub-item', { 'topologies-sub-item-active': isActive, 'topologies-sub-item-matched': searchMatchCount }); @@ -50,7 +50,7 @@ class Topologies extends React.Component { const isActive = topology === this.props.currentTopology; const searchMatches = this.props.searchNodeMatches.get(topology.get('id')); const searchMatchCount = searchMatches ? searchMatches.size : 0; - const className = cx('topologies-item-main', { + const className = classnames('topologies-item-main', { 'topologies-item-main-active': isActive, 'topologies-item-main-matched': searchMatchCount }); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 2a7eb1a71..3f0349802 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -340,6 +340,8 @@ export function rootReducer(state = initialState, action) { 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(); @@ -370,6 +372,7 @@ export function rootReducer(state = initialState, action) { } 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; @@ -517,7 +520,9 @@ export function rootReducer(state = initialState, action) { case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: { // not sure if mergeDeep() brings any benefit here - return state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes)); + state = state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes)); + state = updateNodeMatches(state); + return state; } case ActionTypes.RECEIVE_NOT_FOUND: { diff --git a/client/app/scripts/utils/__tests__/search-utils-test.js b/client/app/scripts/utils/__tests__/search-utils-test.js new file mode 100644 index 000000000..c9d20dfbf --- /dev/null +++ b/client/app/scripts/utils/__tests__/search-utils-test.js @@ -0,0 +1,300 @@ +jest.dontMock('../search-utils'); +jest.dontMock('../string-utils'); +jest.dontMock('../../constants/naming'); // edge naming: 'source-target' + +import { fromJS } from 'immutable'; + +const SearchUtils = require('../search-utils').testable; + +describe('SearchUtils', () => { + const nodeSets = { + someNodes: fromJS({ + n1: { + id: 'n1', + label: 'node label 1', + metadata: [{ + id: 'fieldId1', + label: 'Label 1', + value: 'value 1' + }], + metrics: [{ + id: 'metric1', + label: 'Metric 1', + value: 1 + }] + }, + n2: { + id: 'n2', + label: 'node label 2', + metadata: [{ + id: 'fieldId2', + label: 'Label 2', + value: 'value 2' + }], + tables: [{ + id: 'metric1', + rows: [{ + id: 'row1', + label: 'Row 1', + value: 'Row Value 1' + }] + }], + }, + }) + }; + + describe('applyPinnedSearches', () => { + const fun = SearchUtils.applyPinnedSearches; + + it('should not filter anything when no pinned searches present', () => { + let nextState = fromJS({ + nodes: nodeSets.someNodes, + pinnedSearches: [] + }); + nextState = fun(nextState); + expect(nextState.get('nodes').filter(node => node.get('filtered')).size).toEqual(0); + }); + + it('should filter nodes if nothing matches a pinned search', () => { + let nextState = fromJS({ + nodes: nodeSets.someNodes, + pinnedSearches: ['cantmatch'] + }); + nextState = fun(nextState); + expect(nextState.get('nodes').filterNot(node => node.get('filtered')).size).toEqual(0); + }); + + it('should filter nodes if nothing matches a combination of pinned searches', () => { + let nextState = fromJS({ + nodes: nodeSets.someNodes, + pinnedSearches: ['node label 1', 'node label 2'] + }); + nextState = fun(nextState); + expect(nextState.get('nodes').filterNot(node => node.get('filtered')).size).toEqual(0); + }); + + it('should filter nodes that do not match a pinned searches', () => { + let nextState = fromJS({ + nodes: nodeSets.someNodes, + pinnedSearches: ['row'] + }); + nextState = fun(nextState); + expect(nextState.get('nodes').filter(node => node.get('filtered')).size).toEqual(1); + }); + }); + + describe('findNodeMatch', () => { + const fun = SearchUtils.findNodeMatch; + + it('does not add a non-matching field', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 'some value', 'some query', null, 'some label'); + expect(matches.size).toBe(0); + }); + + it('adds a matching field', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 'samevalue', 'samevalue', null, 'some label'); + expect(matches.size).toBe(1); + expect(matches.getIn(['node1', 'field1'])).toBeDefined(); + const {text, label, start, length} = matches.getIn(['node1', 'field1']); + expect(text).toBe('samevalue'); + expect(label).toBe('some label'); + expect(start).toBe(0); + expect(length).toBe(9); + }); + + it('does not add a field when the prefix does not match the label', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 'samevalue', 'samevalue', 'some prefix', 'some label'); + expect(matches.size).toBe(0); + }); + + it('adds a field when the prefix matches the label', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 'samevalue', 'samevalue', 'prefix', 'prefixed label'); + expect(matches.size).toBe(1); + }); + }); + + describe('findNodeMatchMetric', () => { + const fun = SearchUtils.findNodeMatchMetric; + + it('does not add a non-matching field', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 1, 'metric1', 'metric2', 'lt', 2); + expect(matches.size).toBe(0); + }); + + it('adds a matching field', () => { + let matches = fromJS({}); + matches = fun(matches, ['node1', 'field1'], + 1, 'metric1', 'metric1', 'lt', 2); + expect(matches.size).toBe(1); + expect(matches.getIn(['node1', 'field1'])).toBeDefined(); + const { metric } = matches.getIn(['node1', 'field1']); + expect(metric).toBeTruthy(); + + matches = fun(matches, ['node2', 'field1'], + 1, 'metric1', 'metric1', 'gt', 0); + expect(matches.size).toBe(2); + + matches = fun(matches, ['node3', 'field1'], + 1, 'metric1', 'metric1', 'eq', 1); + expect(matches.size).toBe(3); + + matches = fun(matches, ['node3', 'field1'], + 1, 'metric1', 'metric1', 'other', 1); + expect(matches.size).toBe(3); + }); + }); + + describe('makeRegExp', () => { + const fun = SearchUtils.makeRegExp; + + it('should make a regexp from any string', () => { + expect(fun().source).toEqual((new RegExp).source); + expect(fun('que').source).toEqual((new RegExp('que')).source); + // invalid string + expect(fun('que[').source).toEqual((new RegExp('que\\[')).source); + }); + }); + + describe('matchPrefix', () => { + const fun = SearchUtils.matchPrefix; + + it('returns true if the prefix matches the label', () => { + expect(fun('label', 'prefix')).toBeFalsy(); + expect(fun('memory', 'mem')).toBeTruthy(); + expect(fun('mem', 'memory')).toBeFalsy(); + expect(fun('com.domain.label', 'label')).toBeTruthy(); + expect(fun('com.domain.Label', 'domainlabel')).toBeTruthy(); + expect(fun('com-Domain-label', 'domainlabel')).toBeTruthy(); + expect(fun('memory', 'mem.ry')).toBeTruthy(); + }); + }); + + describe('parseQuery', () => { + const fun = SearchUtils.parseQuery; + + it('should parse a metric value from a string', () => { + expect(fun('')).toEqual(null); + expect(fun('text')).toEqual({query: 'text'}); + expect(fun('prefix:text')).toEqual({prefix: 'prefix', query: 'text'}); + expect(fun(':text')).toEqual(null); + expect(fun('text:')).toEqual(null); + expect(fun('cpu > 1')).toEqual({metric: 'cpu', value: 1, comp: 'gt'}); + expect(fun('cpu >')).toEqual(null); + }); + }); + + describe('parseValue', () => { + const fun = SearchUtils.parseValue; + + it('should parse a metric value from a string', () => { + expect(fun('1')).toEqual(1); + expect(fun('1.34%')).toEqual(1.34); + expect(fun('10kB')).toEqual(1024 * 10); + expect(fun('1K')).toEqual(1024); + expect(fun('2KB')).toEqual(2048); + expect(fun('1MB')).toEqual(Math.pow(1024, 2)); + expect(fun('1m')).toEqual(Math.pow(1024, 2)); + expect(fun('1GB')).toEqual(Math.pow(1024, 3)); + expect(fun('1TB')).toEqual(Math.pow(1024, 4)); + }); + }); + + describe('searchTopology', () => { + const fun = SearchUtils.searchTopology; + + it('should return no matches on an empty topology', () => { + const nodes = fromJS({}); + const matches = fun(nodes, {query: 'value'}); + expect(matches.size).toEqual(0); + }); + + it('should match on a node label', () => { + const nodes = nodeSets.someNodes; + let matches = fun(nodes, {query: 'node label 1'}); + expect(matches.size).toEqual(1); + matches = fun(nodes, {query: 'node label'}); + expect(matches.size).toEqual(2); + }); + + it('should match on a metadata field', () => { + const nodes = nodeSets.someNodes; + const matches = fun(nodes, {query: 'value'}); + expect(matches.size).toEqual(2); + expect(matches.getIn(['n1', 'metadata', 'fieldId1']).text).toEqual('value 1'); + }); + + it('should match on a metric field', () => { + const nodes = nodeSets.someNodes; + const matches = fun(nodes, {metric: 'metric1', value: 1, comp: 'eq'}); + expect(matches.size).toEqual(1); + expect(matches.getIn(['n1', 'metrics', 'metric1']).metric).toBeTruthy(); + }); + + it('should match on a tables field', () => { + const nodes = nodeSets.someNodes; + const matches = fun(nodes, {query: 'Row Value 1'}); + expect(matches.size).toEqual(1); + expect(matches.getIn(['n2', 'metadata', 'row1']).text).toBe('Row Value 1'); + }); + }); + + describe('updateNodeMatches', () => { + const fun = SearchUtils.updateNodeMatches; + + it('should return no matches on an empty topology', () => { + let nextState = fromJS({ + nodesByTopology: {}, + searchNodeMatches: {}, + searchQuery: '' + }); + nextState = fun(nextState); + expect(nextState.get('searchNodeMatches').size).toEqual(0); + }); + + it('should return no matches when no query is present', () => { + let nextState = fromJS({ + nodesByTopology: {topo1: nodeSets.someNodes}, + searchNodeMatches: {}, + searchQuery: '' + }); + nextState = fun(nextState); + expect(nextState.get('searchNodeMatches').size).toEqual(0); + }); + + it('should return no matches when query matches nothing', () => { + let nextState = fromJS({ + nodesByTopology: {topo1: nodeSets.someNodes}, + searchNodeMatches: {}, + searchQuery: 'cantmatch' + }); + nextState = fun(nextState); + expect(nextState.get('searchNodeMatches').size).toEqual(0); + }); + + it('should return a matches when a query matches something', () => { + let nextState = fromJS({ + nodesByTopology: {topo1: nodeSets.someNodes}, + searchNodeMatches: {}, + searchQuery: 'value 2' + }); + nextState = fun(nextState); + expect(nextState.get('searchNodeMatches').size).toEqual(1); + expect(nextState.get('searchNodeMatches').get('topo1').size).toEqual(1); + + // then clear up again + nextState = nextState.set('searchQuery', ''); + nextState = fun(nextState); + expect(nextState.get('searchNodeMatches').size).toEqual(0); + }); + }); +}); diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index ffe42c164..9e92a0bf9 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -8,13 +8,18 @@ import { route } from '../actions/app-actions'; // const SLASH = '/'; const SLASH_REPLACEMENT = ''; +const PERCENT = '%'; +const PERCENT_REPLACEMENT = ''; function encodeURL(url) { - return url.replace(new RegExp(SLASH, 'g'), SLASH_REPLACEMENT); + return url + .replace(new RegExp(PERCENT, 'g'), PERCENT_REPLACEMENT) + .replace(new RegExp(SLASH, 'g'), SLASH_REPLACEMENT); } function decodeURL(url) { - return decodeURIComponent(url.replace(new RegExp(SLASH_REPLACEMENT, 'g'), SLASH)); + return decodeURIComponent(url.replace(new RegExp(SLASH_REPLACEMENT, 'g'), SLASH)) + .replace(new RegExp(PERCENT_REPLACEMENT, 'g'), PERCENT); } function shouldReplaceState(prevState, nextState) { @@ -71,7 +76,7 @@ export function getRouter(dispatch, initialState) { }); page('/state/:state', (ctx) => { - const state = JSON.parse(ctx.params.state); + const state = JSON.parse(decodeURL(ctx.params.state)); dispatch(route(state)); }); diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 4ec786dd3..92bdc8c9b 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -18,6 +18,10 @@ const COMPARISONS_REGEX = new RegExp(`[${COMPARISONS.keySeq().toJS().join('')}]` const PREFIX_DELIMITER = ':'; +/** + * Returns a RegExp from a given string. If the string is not a valid regexp, + * it is escaped. Returned regexp is case-insensitive. + */ function makeRegExp(expression, options = 'i') { try { return new RegExp(expression, options); @@ -26,20 +30,27 @@ function makeRegExp(expression, options = 'i') { } } +/** + * Returns the float of a metric value string, e.g. 2 KB -> 2048 + */ function parseValue(value) { let parsed = parseFloat(value); - if (_.endsWith(value, 'KB')) { + if ((/k/i).test(value)) { parsed *= 1024; - } else if (_.endsWith(value, 'MB')) { + } else if ((/m/i).test(value)) { parsed *= 1024 * 1024; - } else if (_.endsWith(value, 'GB')) { + } else if ((/g/i).test(value)) { parsed *= 1024 * 1024 * 1024; - } else if (_.endsWith(value, 'TB')) { + } else if ((/t/i).test(value)) { parsed *= 1024 * 1024 * 1024 * 1024; } return parsed; } +/** + * True if a prefix matches a field label + * Slugifies the label (removes all non-alphanumerical chars). + */ function matchPrefix(label, prefix) { if (label && prefix) { return (makeRegExp(prefix)).test(slugify(label)); @@ -47,6 +58,12 @@ function matchPrefix(label, prefix) { return false; } +/** + * Adds a match to nodeMatches under the keyPath. The text is matched against + * the query. If a prefix is given, it is matched against the label (skip on + * no match). + * Returns a new instance of nodeMatches. + */ function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { if (!prefix || matchPrefix(label, prefix)) { const queryRe = makeRegExp(query); @@ -55,7 +72,7 @@ function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) { const firstMatch = matches[0]; const index = text.search(queryRe); nodeMatches = nodeMatches.setIn(keyPath, - {text, label, matches: [{start: index, length: firstMatch.length}]}); + {text, label, start: index, length: firstMatch.length}); } } return nodeMatches; @@ -99,6 +116,7 @@ function findNodeMatchMetric(nodeMatches, keyPath, fieldValue, fieldLabel, metri return nodeMatches; } + export function searchTopology(nodes, { prefix, query, metric, comp, value }) { let nodeMatches = makeMap(); nodes.forEach((node, nodeId) => { @@ -106,8 +124,10 @@ export function searchTopology(nodes, { prefix, query, metric, comp, value }) { // top level fields SEARCH_FIELDS.forEach((field, label) => { const keyPath = [nodeId, label]; - nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), - query, prefix, label); + if (node.has(field)) { + nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field), + query, prefix, label); + } }); // metadata @@ -146,6 +166,12 @@ export function searchTopology(nodes, { prefix, query, metric, comp, value }) { return nodeMatches; } +/** + * Returns an object with fields depending on the query: + * parseQuery('text') -> {query: 'text'} + * parseQuery('p:text') -> {query: 'text', prefix: 'p'} + * parseQuery('cpu > 1') -> {metric: 'cpu', value: '1', comp: 'gt'} + */ export function parseQuery(query) { if (query) { const prefixQuery = query.split(PREFIX_DELIMITER); @@ -195,14 +221,17 @@ export function parseQuery(query) { export function updateNodeMatches(state) { const parsed = parseQuery(state.get('searchQuery')); if (parsed) { - state.get('topologyUrlsById').forEach((url, topologyId) => { - const topologyNodes = state.getIn(['nodesByTopology', topologyId]); - if (topologyNodes) { - const nodeMatches = searchTopology(topologyNodes, parsed); - state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); - } - }); - } else { + if (state.has('nodesByTopology')) { + state.get('nodesByTopology').forEach((nodes, topologyId) => { + const nodeMatches = searchTopology(nodes, parsed); + if (nodeMatches.size > 0) { + state = state.setIn(['searchNodeMatches', topologyId], nodeMatches); + } else { + state = state.deleteIn(['searchNodeMatches', topologyId]); + } + }); + } + } else if (state.has('searchNodeMatches')) { state = state.update('searchNodeMatches', snm => snm.clear()); } @@ -235,3 +264,15 @@ export function applyPinnedSearches(state) { return state; } + +export const testable = { + applyPinnedSearches, + findNodeMatch, + findNodeMatchMetric, + matchPrefix, + makeRegExp, + parseQuery, + parseValue, + searchTopology, + updateNodeMatches +}; diff --git a/client/app/styles/contrast.less b/client/app/styles/contrast.less index b99284b2b..2e3575acd 100644 --- a/client/app/styles/contrast.less +++ b/client/app/styles/contrast.less @@ -28,3 +28,6 @@ @btn-opacity-selected: 1; @link-opacity-default: 1; + +@search-border-color: @background-darker-color; +@search-border-width: 2px; diff --git a/client/app/styles/main.less b/client/app/styles/main.less index b6ce58573..519c0bb67 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -60,6 +60,9 @@ @link-opacity-default: 0.8; +@search-border-color: transparent; +@search-border-width: 1px; + /* add this class to truncate text with ellipsis, container needs width */ .truncate { white-space: nowrap; @@ -319,12 +322,12 @@ h2 { top: 0px; } - .logo { + .logo, .node-label-svg { display: none; } svg.exported { - .logo { + .logo, .node-label-svg { display: inline; } } @@ -332,29 +335,42 @@ h2 { text { font-family: @base-font; fill: @text-secondary-color; - - &.node-label { - fill: @text-color; - } - - &.node-sublabel { - fill: @text-secondary-color; - } } .nodes-chart-nodes > .node { - cursor: pointer; transition: opacity .5s @base-ease; + text-align: center; - .hover-box { - fill-opacity: 0; + .node-label, + .node-sublabel { + line-height: 125%; } - &.hovered .node-label, &.hovered .node-sublabel { - stroke: @background-average-color; - stroke-width: 8px; - stroke-opacity: 0.7; - paint-order: stroke; + .node-label { + color: @text-color; + font-size: 14px; + } + + .node-label-wrapper { + display: inline-block; + cursor: pointer; + padding-top: 6px; + } + + .node-sublabel { + color: @text-secondary-color; + font-size: 12px; + } + + &.hovered { + .node-label, .node-sublabel { + span:not(.match) { + background-color: fade(@background-average-color, 70%); + } + } + .matched-results { + background-color: fade(@background-average-color, 70%); + } } &.pseudo { @@ -443,6 +459,7 @@ h2 { .shape { transform: scale(1); + cursor: pointer; /* cloud paths have stroke-width set dynamically */ &:not(.shape-cloud) .border { @@ -516,8 +533,9 @@ h2 { &-more { text-transform: uppercase; - font-size: 0.7rem; - color: @text-tertiary-color; + font-size: 0.6rem; + color: darken(@weave-blue, 10%); + margin-top: -2px; } } @@ -1229,13 +1247,10 @@ h2 { display: flex; border-radius: @border-radius; width: 100%; - border: 1px solid transparent; + border: @search-border-width solid @search-border-color; padding: 2px 4px; text-align: left; - - &-items { - padding: 2px 4px; - } + flex-wrap: wrap; &-field { font-size: 0.8rem; @@ -1247,6 +1262,7 @@ h2 { background: transparent; color: @text-color; flex: 1; + width: 60px; &:focus { outline: none; @@ -1259,14 +1275,15 @@ h2 { text-align: center; color: @text-secondary-color; position: relative; - top: 4px; + top: 2px; left: 4px; + padding: 2px; } &-label { user-select: none; display: inline-block; - padding: 2px 1em; + padding: 2px 0.75em; font-size: 0.8rem; position: absolute; text-align: left; @@ -1308,7 +1325,12 @@ h2 { .search-item { background-color: fade(@weave-blue, 20%); border-radius: @border-radius / 2; - margin-left: 4px; + margin: 1px 0 1px 8px; + display: inline-block; + + & + .search-item { + margin-left: 4px; + } &-label { padding: 2px 4px; From 5ee0e082a5e10cff61a611141ff71707d7594d5f Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 May 2016 13:33:52 +0200 Subject: [PATCH 10/17] Fix auto-truncation for node labels --- client/app/styles/main.less | 1 - 1 file changed, 1 deletion(-) diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 519c0bb67..434fdf4f4 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -352,7 +352,6 @@ h2 { } .node-label-wrapper { - display: inline-block; cursor: pointer; padding-top: 6px; } From 0573a20f076718dad52fb3a92393ae6609a9b447 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 May 2016 17:30:57 +0200 Subject: [PATCH 11/17] Store table matches in table object --- client/app/scripts/components/node-details.js | 2 +- client/app/scripts/utils/__tests__/search-utils-test.js | 2 +- client/app/scripts/utils/search-utils.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index c0e8363c2..6e4d688d8 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -212,7 +212,7 @@ export class NodeDetails extends React.Component { }
+ matches={nodeMatches.get('tables')} /> ); } diff --git a/client/app/scripts/utils/__tests__/search-utils-test.js b/client/app/scripts/utils/__tests__/search-utils-test.js index c9d20dfbf..37bc76d60 100644 --- a/client/app/scripts/utils/__tests__/search-utils-test.js +++ b/client/app/scripts/utils/__tests__/search-utils-test.js @@ -244,7 +244,7 @@ describe('SearchUtils', () => { const nodes = nodeSets.someNodes; const matches = fun(nodes, {query: 'Row Value 1'}); expect(matches.size).toEqual(1); - expect(matches.getIn(['n2', 'metadata', 'row1']).text).toBe('Row Value 1'); + expect(matches.getIn(['n2', 'tables', 'row1']).text).toBe('Row Value 1'); }); }); diff --git a/client/app/scripts/utils/search-utils.js b/client/app/scripts/utils/search-utils.js index 92bdc8c9b..ca7bba369 100644 --- a/client/app/scripts/utils/search-utils.js +++ b/client/app/scripts/utils/search-utils.js @@ -145,7 +145,7 @@ export function searchTopology(nodes, { prefix, query, metric, comp, value }) { tables.forEach((table) => { if (table.get('rows')) { table.get('rows').forEach(field => { - const keyPath = [nodeId, 'metadata', field.get('id')]; + const keyPath = [nodeId, 'tables', field.get('id')]; nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'), query, prefix, field.get('label')); }); From 9984777a5bd4b400ee294ef11bc200ef789a459a Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 May 2016 17:47:21 +0200 Subject: [PATCH 12/17] Show connected nodes on hover, even when not matched --- client/app/scripts/charts/nodes-chart-edges.js | 11 +++++++---- client/app/scripts/charts/nodes-chart-nodes.js | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index b643d79fa..5ee081fa1 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -16,10 +16,13 @@ class NodesChartEdges extends React.Component { {layoutEdges.toIndexedSeq().map(edge => { const sourceSelected = selectedNodeId === edge.get('source'); const targetSelected = selectedNodeId === edge.get('target'); - const blurred = hasSelectedNode && !sourceSelected && !targetSelected - || searchQuery && !(searchNodeMatches.has(edge.get('source')) - && searchNodeMatches.has(edge.get('target'))); + const highlighted = highlightedEdgeIds.has(edge.get('id')); const focused = hasSelectedNode && (sourceSelected || targetSelected); + const blurred = !focused + && !highlighted + && (!searchQuery + || !(searchNodeMatches.has(edge.get('source')) + && searchNodeMatches.has(edge.get('target')))); return ( ); })} diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 878e18206..8304aa634 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -22,7 +22,8 @@ class NodesChartNodes extends React.Component { || (adjacentNodes && adjacentNodes.includes(node.get('id'))))); const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused') - || searchQuery && !searchNodeMatches.has(node.get('id'))); + || searchQuery && !searchNodeMatches.has(node.get('id')) + && !node.get('highlighted')); // make sure blurred nodes are in the background const sortNodes = node => { From 50935dd45662eee7a66f5ea12f440c78b1266a4f Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 May 2016 18:33:42 +0200 Subject: [PATCH 13/17] Fetch topologies sequentially on search --- client/app/scripts/utils/web-api-utils.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 570ac25a5..f54a70357 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -102,12 +102,15 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) { export function getAllNodes(getState, dispatch) { const state = getState(); const topologyOptions = state.get('topologyOptions'); - state.get('topologyUrlsById').forEach((topologyUrl, topologyId) => { - const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId)); - fetch(`${topologyUrl}?${optionsQuery}`) - .then(response => response.json()) - .then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))); - }); + // fetch sequentially + state.get('topologyUrlsById') + .reduce((sequence, topologyUrl, topologyId) => sequence.then(() => { + const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId)); + return fetch(`${topologyUrl}?${optionsQuery}`); + }) + .then(response => response.json()) + .then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))), + Promise.resolve()); } export function getTopologies(options, dispatch) { From 14e06e03baa6f3ed98b4e5fbdb4213f05f6f23e4 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 10 May 2016 20:58:57 +0200 Subject: [PATCH 14/17] Fix typo --- client/app/scripts/components/search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/scripts/components/search.js b/client/app/scripts/components/search.js index 688325946..8b10b71f6 100644 --- a/client/app/scripts/components/search.js +++ b/client/app/scripts/components/search.js @@ -54,7 +54,7 @@ class Search extends React.Component { const inputValue = ev.target.value; let value = inputValue; // In render() props.searchQuery can be set from the outside, but state.value - // must have precendence for quick feedback. Now when the user backspaces + // must have precedence for quick feedback. Now when the user backspaces // quickly enough from `text`, a previouse doSearch(`text`) will come back // via props and override the empty state.value. To detect this edge case // we instead set value to null when backspacing. From af3f18b933e63a33d9c5b4de25cb99049e3f07b3 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Tue, 10 May 2016 22:26:54 +0200 Subject: [PATCH 15/17] Don't draw svg labels when we don't need them. Having the DOM nodes w/ display:none is still expensive. We only need them briefly for svg export. --- client/app/scripts/actions/app-actions.js | 4 +- client/app/scripts/charts/node.js | 55 ++++++++++++-------- client/app/scripts/constants/action-types.js | 3 +- client/app/scripts/reducers/root.js | 7 ++- client/app/styles/main.less | 4 +- 5 files changed, 47 insertions(+), 26 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 85a6246eb..d5eaa6191 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -143,8 +143,10 @@ export function clickCloseTerminal(pipeId, closePipe) { } export function clickDownloadGraph() { - return () => { + return (dispatch) => { + dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: true }); saveGraph(); + dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: false }); }; } diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index 76c517b7d..f28f52719 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -37,6 +37,17 @@ function getNodeShape({ shape, stack }) { return stack ? stackedShape(nodeShape) : nodeShape; } +function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) { + return ( + + {label} + + {subLabel} + + + ); +} + class Node extends React.Component { constructor(props, context) { @@ -65,7 +76,7 @@ class Node extends React.Component { render() { const { blurred, focused, highlighted, label, matches = makeMap(), - pseudo, rank, subLabel, scaleFactor, transform, zoomScale } = this.props; + pseudo, rank, subLabel, scaleFactor, transform, zoomScale, exportingGraph } = this.props; const { hovered, matched } = this.state; const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale; @@ -88,30 +99,29 @@ class Node extends React.Component { const subLabelClassName = classnames('node-sublabel', { truncate }); const NodeShapeType = getNodeShape(this.props); + const useSvgLabels = exportingGraph; return ( - {/* For browser */} - -
-
- + + {useSvgLabels ? + + svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) : + + +
+
+ +
+
+ +
+ {!blurred && }
-
- -
- {!blurred && } -
- - {/* For SVG export */} - - {label} - - {subLabel} - - + } + ({ searchQuery: state.get('searchQuery') }), + state => ({ + searchQuery: state.get('searchQuery'), + exportingGraph: state.get('exportingGraph') + }), { clickNode, enterNode, leaveNode } )(Node); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 18e42a69f..5d2b85277 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -46,7 +46,8 @@ const ACTION_TYPES = [ 'RECEIVE_ERROR', 'ROUTE_TOPOLOGY', 'SELECT_METRIC', - 'SHOW_HELP' + 'SHOW_HELP', + 'SET_EXPORTING_GRAPH' ]; export default _.zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 3f0349802..de3669641 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -70,7 +70,8 @@ export const initialState = makeMap({ updatePausedAt: null, // Date version: '...', versionUpdate: null, - websocketClosed: true + websocketClosed: true, + exportingGraph: false }); // adds ID field to topology (based on last part of URL path) and save urls in @@ -169,6 +170,10 @@ export function rootReducer(state = initialState, action) { 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']); } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 434fdf4f4..1ead30216 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -322,12 +322,12 @@ h2 { top: 0px; } - .logo, .node-label-svg { + .logo { display: none; } svg.exported { - .logo, .node-label-svg { + .logo { display: inline; } } From 0a46d6128c337240f694458198123baead698ea3 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Wed, 11 May 2016 09:50:44 +0200 Subject: [PATCH 16/17] Fixes node-position layout caching. Key was not being generated correctly. --- client/app/scripts/charts/nodes-chart.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 59ed21351..3e957a4f5 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -403,7 +403,7 @@ function mapStateToProps(state) { forceRelayout: state.get('forceRelayout'), nodes: state.get('nodes').filter(node => !node.get('filtered')), selectedNodeId: state.get('selectedNodeId'), - topologyId: state.get('topologyId'), + topologyId: state.get('currentTopologyId'), topologyOptions: getActiveTopologyOptions(state) }; } From b51e7a95c579922206022dcd096389b9dec3c7a5 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 11 May 2016 12:23:28 +0200 Subject: [PATCH 17/17] Fix edge hiding Fixes #1471 --- client/app/scripts/charts/nodes-chart-edges.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 5ee081fa1..29d26ffe0 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -18,11 +18,10 @@ class NodesChartEdges extends React.Component { const targetSelected = selectedNodeId === edge.get('target'); const highlighted = highlightedEdgeIds.has(edge.get('id')); const focused = hasSelectedNode && (sourceSelected || targetSelected); - const blurred = !focused - && !highlighted - && (!searchQuery - || !(searchNodeMatches.has(edge.get('source')) - && searchNodeMatches.has(edge.get('target')))); + const blurred = !(highlightedEdgeIds.size > 0 && highlighted) + && ((hasSelectedNode && !sourceSelected && !targetSelected) + || !focused && searchQuery && !(searchNodeMatches.has(edge.get('source')) + && searchNodeMatches.has(edge.get('target')))); return (