@@ -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..5d2b85277 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -1,6 +1,8 @@
import _ from 'lodash';
const ACTION_TYPES = [
+ 'ADD_QUERY_FILTER',
+ 'BLUR_SEARCH',
'CHANGE_TOPOLOGY_OPTION',
'CLEAR_CONTROL_ERROR',
'CLICK_BACKGROUND',
@@ -19,13 +21,17 @@ 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',
'PIN_METRIC',
+ 'PIN_SEARCH',
'UNPIN_METRIC',
+ 'UNPIN_SEARCH',
'OPEN_WEBSOCKET',
'RECEIVE_CONTROL_NODE_REMOVED',
'RECEIVE_CONTROL_PIPE',
@@ -33,13 +39,15 @@ const ACTION_TYPES = [
'RECEIVE_NODE_DETAILS',
'RECEIVE_NODES',
'RECEIVE_NODES_DELTA',
+ 'RECEIVE_NODES_FOR_TOPOLOGY',
'RECEIVE_NOT_FOUND',
'RECEIVE_TOPOLOGIES',
'RECEIVE_API_DETAILS',
'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/__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
}
};
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js
index e9711554b..de3669641 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 { applyPinnedSearches, 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,34 @@ 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(),
+ pinnedSearches: makeList(), // list of node filters
+ 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,
+ exportingGraph: false
});
// adds ID field to topology (based on last part of URL path) and save urls in
@@ -142,6 +149,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
@@ -159,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']);
}
@@ -305,6 +320,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 => {
@@ -325,6 +345,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();
@@ -355,6 +377,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;
@@ -380,6 +403,18 @@ export function rootReducer(state = initialState, action) {
}));
}
+ case ActionTypes.FOCUS_SEARCH: {
+ return state.set('searchFocused', true);
+ }
+
+ case ActionTypes.PIN_SEARCH: {
+ state = state.set('searchQuery', '');
+ state = updateNodeMatches(state);
+ const pinnedSearches = state.get('pinnedSearches');
+ state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query);
+ return applyPinnedSearches(state);
+ }
+
case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: {
return closeNodeDetails(state, action.nodeId);
}
@@ -458,6 +493,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 => (
@@ -478,6 +516,17 @@ 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
+ state = state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
+ state = updateNodeMatches(state);
return state;
}
@@ -519,6 +568,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());
}
@@ -551,6 +602,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/__tests__/search-utils-test.js b/client/app/scripts/utils/__tests__/search-utils-test.js
new file mode 100644
index 000000000..37bc76d60
--- /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', 'tables', '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 85cfc7d1f..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) {
@@ -35,8 +40,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
};
@@ -69,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
new file mode 100644
index 000000000..ca7bba369
--- /dev/null
+++ b/client/app/scripts/utils/search-utils.js
@@ -0,0 +1,278 @@
+import { Map as makeMap } from 'immutable';
+import _ from 'lodash';
+
+import { slugify } from './string-utils';
+
+// topolevel search fields
+const SEARCH_FIELDS = makeMap({
+ label: 'label',
+ sublabel: 'label_minor'
+});
+
+const COMPARISONS = makeMap({
+ '<': 'lt',
+ '>': 'gt',
+ '=': 'eq'
+});
+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);
+ } catch (e) {
+ return new RegExp(_.escapeRegExp(expression), options);
+ }
+}
+
+/**
+ * Returns the float of a metric value string, e.g. 2 KB -> 2048
+ */
+function parseValue(value) {
+ let parsed = parseFloat(value);
+ if ((/k/i).test(value)) {
+ parsed *= 1024;
+ } else if ((/m/i).test(value)) {
+ parsed *= 1024 * 1024;
+ } else if ((/g/i).test(value)) {
+ parsed *= 1024 * 1024 * 1024;
+ } 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));
+ }
+ 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);
+ const matches = text.match(queryRe);
+ if (matches) {
+ const firstMatch = matches[0];
+ const index = text.search(queryRe);
+ nodeMatches = nodeMatches.setIn(keyPath,
+ {text, label, start: index, length: firstMatch.length});
+ }
+ }
+ return nodeMatches;
+}
+
+/**
+ * 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) => {
+ if (query) {
+ // top level fields
+ SEARCH_FIELDS.forEach((field, label) => {
+ const keyPath = [nodeId, label];
+ if (node.has(field)) {
+ 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'));
+ });
+ }
+
+ // 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, 'tables', 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;
+}
+
+/**
+ * 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);
+ const isPrefixQuery = prefixQuery && prefixQuery.length === 2;
+
+ if (isPrefixQuery) {
+ const prefix = prefixQuery[0].trim();
+ query = prefixQuery[1].trim();
+ if (prefix && query) {
+ 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;
+}
+
+/**
+ * Returns {topologyId: {nodeId: matches}}
+ */
+export function updateNodeMatches(state) {
+ const parsed = parseQuery(state.get('searchQuery'));
+ if (parsed) {
+ 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());
+ }
+
+ 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;
+}
+
+export const testable = {
+ applyPinnedSearches,
+ findNodeMatch,
+ findNodeMatchMetric,
+ matchPrefix,
+ makeRegExp,
+ parseQuery,
+ parseValue,
+ searchTopology,
+ updateNodeMatches
+};
diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js
index 9b7c22df4..5f8d180d5 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, '').toLowerCase();
+}
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..f54a70357 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,23 @@ 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');
+ // 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) {
clearTimeout(topologyTimer);
const optionsQuery = buildOptionsQuery(options);
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 43132ba89..1ead30216 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;
@@ -170,7 +173,7 @@ h2 {
display: flex;
.logo {
- margin: -8px 0 0 64px;
+ margin: -10px 0 0 64px;
height: 64px;
width: 250px;
}
@@ -236,7 +239,7 @@ h2 {
}
.topologies {
- margin: 4px 0 0 128px;
+ margin: 8px 4px;
display: flex;
.topologies-item {
@@ -268,6 +271,7 @@ h2 {
border-radius: @border-radius;
opacity: 0.8;
margin-bottom: 3px;
+ border: 1px solid transparent;
&-active, &:hover {
color: @text-color;
@@ -277,6 +281,11 @@ h2 {
&-active {
opacity: 0.85;
}
+
+ &-matched {
+ border-color: @weave-blue;
+ }
+
}
.topologies-sub-item {
@@ -326,29 +335,41 @@ 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 {
+ 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 {
@@ -375,6 +396,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 +457,9 @@ h2 {
}
.shape {
+ transform: scale(1);
+ cursor: pointer;
+
/* cloud paths have stroke-width set dynamically */
&:not(.shape-cloud) .border {
stroke-width: @node-border-stroke-width;
@@ -474,6 +511,33 @@ 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.6rem;
+ color: darken(@weave-blue, 10%);
+ margin-top: -2px;
+ }
+}
+
.details {
&-wrapper {
position: fixed;
@@ -526,6 +590,11 @@ h2 {
}
}
+ .match {
+ background-color: fade(@weave-blue, 30%);
+ border: 1px solid @weave-blue;
+ }
+
&-header {
.colorable;
@@ -1141,6 +1210,141 @@ 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;
+ 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: flex;
+ border-radius: @border-radius;
+ width: 100%;
+ border: @search-border-width solid @search-border-color;
+ padding: 2px 4px;
+ text-align: left;
+ flex-wrap: wrap;
+
+ &-field {
+ font-size: 0.8rem;
+ line-height: 150%;
+ position: relative;
+ padding: 1px 4px 1px 0.75em;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: @text-color;
+ flex: 1;
+ width: 60px;
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ &-icon {
+ position: relative;
+ width: 1.285em;
+ text-align: center;
+ color: @text-secondary-color;
+ position: relative;
+ top: 2px;
+ left: 4px;
+ padding: 2px;
+ }
+
+ &-label {
+ user-select: none;
+ display: inline-block;
+ padding: 2px 0.75em;
+ font-size: 0.8rem;
+ position: absolute;
+ text-align: left;
+ pointer-events: none;
+ color: @text-secondary-color;
+ text-transform: uppercase;
+ transition: opacity 0.3s 0.5s @base-ease;
+ opacity: 1;
+ }
+ }
+
+ &-focused &-input-label,
+ &-pinned &-input-label,
+ &-filled &-input-label {
+ transition: opacity 0.1s 0s @base-ease;
+ opacity: 0;
+ }
+
+ &-focused &-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,
+ &-pinned {
+ width: 100%;
+ }
+
+ &-matched &-input {
+ border-color: @weave-blue;
+ }
+
+}
+
+.search-item {
+ background-color: fade(@weave-blue, 20%);
+ border-radius: @border-radius / 2;
+ margin: 1px 0 1px 8px;
+ display: inline-block;
+
+ & + .search-item {
+ 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;
@@ -1159,6 +1363,14 @@ h2 {
}
}
+@keyframes throb {
+ 0%, 50%, 100% {
+ transform: scale(1);
+ } 25%, 75% {
+ transform: scale(1.2);
+ }
+}
+
//
// Help panel!
//