mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +00:00
Apply search as filter
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<g className="nodes-chart-edges">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
28
client/app/scripts/components/search-item.js
Normal file
28
client/app/scripts/components/search-item.js
Normal file
@@ -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 (
|
||||
<span className="search-item">
|
||||
<span className="search-item-label">{this.props.query}</span>
|
||||
<span className="search-item-icon fa fa-close" onClick={this.handleClick} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null, { unpinSearch })(SearchItem);
|
||||
@@ -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 {
|
||||
<div className="search-wrapper">
|
||||
<div className={classNames} title={title}>
|
||||
<div className="search-input">
|
||||
<i className="fa fa-search search-input-icon"></i>
|
||||
<label className="search-input-label" htmlFor={inputId}>
|
||||
Search
|
||||
</label>
|
||||
{showPinnedSearches && <span className="search-input-items">
|
||||
{pinnedSearches.toIndexedSeq()
|
||||
.map(query => <SearchItem query={query} key={query} />)}
|
||||
</span>}
|
||||
<input className="search-input-field" type="text" id={inputId}
|
||||
value={this.state.value} onChange={this.handleChange}
|
||||
onBlur={this.handleBlur} onFocus={this.handleFocus}
|
||||
disabled={disabled} />
|
||||
<label className="search-input-label" htmlFor={inputId}>
|
||||
<i className="fa fa-search search-input-label-icon"></i>
|
||||
<span className="search-input-label-text">Search</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="search-hint">{getHint(this.props.nodes)}</div>
|
||||
{!showPinnedSearches && <div className="search-hint">
|
||||
{getHint(nodes)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user