mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-28 17:51:21 +00:00
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
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ class Footer extends React.Component {
|
||||
<span className="fa fa-refresh" />
|
||||
</a>
|
||||
<a className="footer-icon" onClick={this.props.clickDownloadGraph}
|
||||
title="Save canvas as SVG">
|
||||
title="Save canvas as SVG (does not include search highlighting)">
|
||||
<span className="fa fa-download" />
|
||||
</a>
|
||||
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="matched-results-match" key={match.label}>
|
||||
<div className="matched-results-match-wrapper">
|
||||
<span className="matched-results-match-label">
|
||||
{match.label}:
|
||||
</span>
|
||||
<MatchedText text={match.text} matches={matches} fieldId={field} />
|
||||
<MatchedText text={text} match={match} maxLength={MAX_MATCH_LENGTH} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -41,9 +44,9 @@ class MatchedResults extends React.Component {
|
||||
return (
|
||||
<div className="matched-results">
|
||||
{matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))}
|
||||
{moreFieldMatches && <span className="matched-results-more" title={moreFieldMatchesTitle}>
|
||||
{moreFieldMatches && <div className="matched-results-more" title={moreFieldMatchesTitle}>
|
||||
{`${moreFieldMatches.size} more matches`}
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <span>{text}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="matched-text">
|
||||
{reduceMatchesToChunks(fieldMatches.matches, text).map((chunk, index) => {
|
||||
<span className="matched-text" title={text}>
|
||||
{truncateChunks(chunkText(text, match), text, maxLength).map((chunk, index) => {
|
||||
if (chunk.match) {
|
||||
return (
|
||||
<span className="match" key={index} title={`matched: ${fieldMatches.label}`}>
|
||||
{text.substr(chunk.start, chunk.length)}
|
||||
<span className="match" key={index}>
|
||||
{chunk.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return text.substr(chunk.start, chunk.length);
|
||||
return chunk.text;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -162,7 +162,7 @@ export class NodeDetails extends React.Component {
|
||||
<div className="node-details-header" style={styles.header}>
|
||||
<div className="node-details-header-wrapper">
|
||||
<h2 className="node-details-header-label truncate" title={details.label}>
|
||||
<MatchedText text={details.label} matches={nodeMatches} fieldId="label" />
|
||||
<MatchedText text={details.label} match={nodeMatches.get('label')} />
|
||||
</h2>
|
||||
<div className="node-details-header-relatives">
|
||||
{details.parents && <NodeDetailsRelatives relatives={details.parents} />}
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
<div className="node-details-info-field-value truncate" title={field.value}>
|
||||
<MatchedText text={field.value} matches={matches} fieldId={field.id} />
|
||||
<MatchedText text={field.value} match={matches.get(field.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default class NodeDetailsLabels extends React.Component {
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="node-details-labels-field-value truncate" title={field.value}>
|
||||
<MatchedText text={field.value} matches={matches} fieldId={field.id} />
|
||||
<MatchedText text={field.value} match={matches.get(field.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 {
|
||||
<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>}
|
||||
{showPinnedSearches && pinnedSearches.toIndexedSeq()
|
||||
.map(query => <SearchItem query={query} key={query} />)}
|
||||
<input className="search-input-field" type="text" id={inputId}
|
||||
value={value} onChange={this.handleChange}
|
||||
onBlur={this.handleBlur} onFocus={this.handleFocus}
|
||||
disabled={disabled} />
|
||||
onFocus={this.handleFocus}
|
||||
disabled={disabled} ref="queryInput" />
|
||||
</div>
|
||||
{!showPinnedSearches && <div className="search-hint">
|
||||
{getHint(nodes)}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user