mirror of
https://github.com/grafana/grafana.git
synced 2024-11-28 03:34:15 -06:00
Merge pull request #13536 from grafana/davkal/explore-text-match
Explore: highlight typed text in suggestions
This commit is contained in:
commit
39b25e0596
@ -11,7 +11,7 @@ export enum LogLevel {
|
||||
export interface LogSearchMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
text?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
@ -21,7 +21,7 @@ export interface LogRow {
|
||||
timestamp: string;
|
||||
timeFromNow: string;
|
||||
timeLocal: string;
|
||||
searchMatches?: LogSearchMatch[];
|
||||
searchWords?: string[];
|
||||
}
|
||||
|
||||
export interface LogsModel {
|
||||
|
24
public/app/core/utils/text.test.ts
Normal file
24
public/app/core/utils/text.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { findMatchesInText } from './text';
|
||||
|
||||
describe('findMatchesInText()', () => {
|
||||
it('gets no matches for when search and or line are empty', () => {
|
||||
expect(findMatchesInText('', '')).toEqual([]);
|
||||
expect(findMatchesInText('foo', '')).toEqual([]);
|
||||
expect(findMatchesInText('', 'foo')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets no matches for unmatched search string', () => {
|
||||
expect(findMatchesInText('foo', 'bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets matches for matched search string', () => {
|
||||
expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
|
||||
expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
|
||||
});
|
||||
|
||||
expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
|
||||
{ length: 3, start: 1, text: 'foo', end: 4 },
|
||||
{ length: 3, start: 5, text: 'foo', end: 8 },
|
||||
{ length: 3, start: 9, text: 'bar', end: 12 },
|
||||
]);
|
||||
});
|
32
public/app/core/utils/text.ts
Normal file
32
public/app/core/utils/text.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { TextMatch } from 'app/types/explore';
|
||||
|
||||
/**
|
||||
* Adapt findMatchesInText for react-highlight-words findChunks handler.
|
||||
* See https://github.com/bvaughn/react-highlight-words#props
|
||||
*/
|
||||
export function findHighlightChunksInText({ searchWords, textToHighlight }) {
|
||||
return findMatchesInText(textToHighlight, searchWords.join(' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of substring regexp matches.
|
||||
*/
|
||||
export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
|
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!haystack || !needle) {
|
||||
return [];
|
||||
}
|
||||
const regexp = new RegExp(`(?:${needle})`, 'g');
|
||||
const matches = [];
|
||||
let match = regexp.exec(haystack);
|
||||
while (match) {
|
||||
matches.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
match = regexp.exec(haystack);
|
||||
}
|
||||
return matches;
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import React, { Fragment, PureComponent } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { LogsModel, LogRow } from 'app/core/logs_model';
|
||||
import { LogsModel } from 'app/core/logs_model';
|
||||
import { findHighlightChunksInText } from 'app/core/utils/text';
|
||||
|
||||
interface LogsProps {
|
||||
className?: string;
|
||||
@ -10,34 +12,7 @@ interface LogsProps {
|
||||
|
||||
const EXAMPLE_QUERY = '{job="default/prometheus"}';
|
||||
|
||||
const Entry: React.SFC<LogRow> = props => {
|
||||
const { entry, searchMatches } = props;
|
||||
if (searchMatches && searchMatches.length > 0) {
|
||||
let lastMatchEnd = 0;
|
||||
const spans = searchMatches.reduce((acc, match, i) => {
|
||||
// Insert non-match
|
||||
if (match.start !== lastMatchEnd) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
|
||||
}
|
||||
// Match
|
||||
acc.push(
|
||||
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
|
||||
{entry.substr(match.start, match.length)}
|
||||
</span>
|
||||
);
|
||||
lastMatchEnd = match.start + match.length;
|
||||
// Non-matching end
|
||||
if (i === searchMatches.length - 1) {
|
||||
acc.push(<>{entry.slice(lastMatchEnd)}</>);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return <>{spans}</>;
|
||||
}
|
||||
return <>{props.entry}</>;
|
||||
};
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, any> {
|
||||
export default class Logs extends PureComponent<LogsProps, {}> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
@ -50,7 +25,12 @@ export default class Logs extends PureComponent<LogsProps, any> {
|
||||
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
|
||||
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
|
||||
<div>
|
||||
<Entry {...row} />
|
||||
<Highlighter
|
||||
textToHighlight={row.entry}
|
||||
searchWords={row.searchWords}
|
||||
findChunks={findHighlightChunksInText}
|
||||
highlightClassName="logs-row-match-highlight"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
@ -145,7 +145,7 @@ interface PromQueryFieldProps {
|
||||
onClickHintFix?: (action: any) => void;
|
||||
onPressEnter?: () => void;
|
||||
onQueryChange?: (value: string, override?: boolean) => void;
|
||||
portalPrefix?: string;
|
||||
portalOrigin?: string;
|
||||
request?: (url: string) => any;
|
||||
supportsLogs?: boolean; // To be removed after Logging gets its own query field
|
||||
}
|
||||
@ -571,10 +571,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
<button className="btn navbar-button navbar-button--tight">Log labels</button>
|
||||
</Cascader>
|
||||
) : (
|
||||
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
|
||||
<button className="btn navbar-button navbar-button--tight">Metrics</button>
|
||||
</Cascader>
|
||||
)}
|
||||
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
|
||||
<button className="btn navbar-button navbar-button--tight">Metrics</button>
|
||||
</Cascader>
|
||||
)}
|
||||
</div>
|
||||
<div className="prom-query-field-wrapper">
|
||||
<div className="slate-query-field-wrapper">
|
||||
@ -586,7 +586,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onValueChanged={this.onChangeQuery}
|
||||
placeholder="Enter a PromQL query"
|
||||
portalPrefix="prometheus"
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
@ -104,7 +104,7 @@ interface TypeaheadFieldProps {
|
||||
onValueChanged?: (value: Value) => void;
|
||||
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
|
||||
placeholder?: string;
|
||||
portalPrefix?: string;
|
||||
portalOrigin?: string;
|
||||
syntax?: string;
|
||||
syntaxLoaded?: boolean;
|
||||
}
|
||||
@ -459,8 +459,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
};
|
||||
|
||||
renderMenu = () => {
|
||||
const { portalPrefix } = this.props;
|
||||
const { suggestions, typeaheadIndex } = this.state;
|
||||
const { portalOrigin } = this.props;
|
||||
const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
|
||||
if (!hasSuggestions(suggestions)) {
|
||||
return null;
|
||||
}
|
||||
@ -469,11 +469,12 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
<Portal prefix={portalPrefix}>
|
||||
<Portal origin={portalOrigin}>
|
||||
<Typeahead
|
||||
menuRef={this.menuRef}
|
||||
selectedItem={selectedItem}
|
||||
onClickItem={this.onClickMenu}
|
||||
prefix={typeaheadPrefix}
|
||||
groupedItems={suggestions}
|
||||
/>
|
||||
</Portal>
|
||||
@ -500,14 +501,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
|
||||
}
|
||||
}
|
||||
|
||||
class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
|
||||
class Portal extends React.PureComponent<{ index?: number; origin: string }, {}> {
|
||||
node: HTMLElement;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { index = 0, prefix = 'query' } = props;
|
||||
const { index = 0, origin = 'query' } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
|
@ -53,7 +53,6 @@ class QueryRow extends PureComponent<any, {}> {
|
||||
hint={queryHint}
|
||||
initialQuery={query}
|
||||
history={history}
|
||||
portalPrefix="explore"
|
||||
onClickHintFix={this.onClickHintFix}
|
||||
onPressEnter={this.onPressEnter}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { Suggestion, SuggestionGroup } from './QueryField';
|
||||
|
||||
@ -16,6 +17,7 @@ interface TypeaheadItemProps {
|
||||
isSelected: boolean;
|
||||
item: Suggestion;
|
||||
onClickItem: (Suggestion) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
@ -38,11 +40,12 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSelected, item } = this.props;
|
||||
const { isSelected, item, prefix } = this.props;
|
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||
const { label } = item;
|
||||
return (
|
||||
<li ref={this.getRef} className={className} onClick={this.onClick}>
|
||||
{item.detail || item.label}
|
||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" />
|
||||
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
|
||||
</li>
|
||||
);
|
||||
@ -54,18 +57,25 @@ interface TypeaheadGroupProps {
|
||||
label: string;
|
||||
onClickItem: (Suggestion) => void;
|
||||
selected: Suggestion;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
|
||||
render() {
|
||||
const { items, label, selected, onClickItem } = this.props;
|
||||
const { items, label, selected, onClickItem, prefix } = this.props;
|
||||
return (
|
||||
<li className="typeahead-group">
|
||||
<div className="typeahead-group__title">{label}</div>
|
||||
<ul className="typeahead-group__list">
|
||||
{items.map(item => {
|
||||
return (
|
||||
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
|
||||
<TypeaheadItem
|
||||
key={item.label}
|
||||
onClickItem={onClickItem}
|
||||
isSelected={selected === item}
|
||||
item={item}
|
||||
prefix={prefix}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
@ -79,14 +89,15 @@ interface TypeaheadProps {
|
||||
menuRef: any;
|
||||
selectedItem: Suggestion | null;
|
||||
onClickItem: (Suggestion) => void;
|
||||
prefix?: string;
|
||||
}
|
||||
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
|
||||
render() {
|
||||
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
|
||||
const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
|
||||
return (
|
||||
<ul className="typeahead" ref={menuRef}>
|
||||
{groupedItems.map(g => (
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
@ -1,29 +1,6 @@
|
||||
import { LogLevel } from 'app/core/logs_model';
|
||||
|
||||
import { getLogLevel, getSearchMatches } from './result_transformer';
|
||||
|
||||
describe('getSearchMatches()', () => {
|
||||
it('gets no matches for when search and or line are empty', () => {
|
||||
expect(getSearchMatches('', '')).toEqual([]);
|
||||
expect(getSearchMatches('foo', '')).toEqual([]);
|
||||
expect(getSearchMatches('', 'foo')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets no matches for unmatched search string', () => {
|
||||
expect(getSearchMatches('foo', 'bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gets matches for matched search string', () => {
|
||||
expect(getSearchMatches('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo' }]);
|
||||
expect(getSearchMatches(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo' }]);
|
||||
});
|
||||
|
||||
expect(getSearchMatches(' foo foo bar ', 'foo|bar')).toEqual([
|
||||
{ length: 3, start: 1, text: 'foo' },
|
||||
{ length: 3, start: 5, text: 'foo' },
|
||||
{ length: 3, start: 9, text: 'bar' },
|
||||
]);
|
||||
});
|
||||
import { getLogLevel } from './result_transformer';
|
||||
|
||||
describe('getLoglevel()', () => {
|
||||
it('returns no log level on empty line', () => {
|
||||
|
@ -19,25 +19,6 @@ export function getLogLevel(line: string): LogLevel {
|
||||
return level;
|
||||
}
|
||||
|
||||
export function getSearchMatches(line: string, search: string) {
|
||||
// Empty search can send re.exec() into infinite loop, exit early
|
||||
if (!line || !search) {
|
||||
return [];
|
||||
}
|
||||
const regexp = new RegExp(`(?:${search})`, 'g');
|
||||
const matches = [];
|
||||
let match = regexp.exec(line);
|
||||
while (match) {
|
||||
matches.push({
|
||||
text: match[0],
|
||||
start: match.index,
|
||||
length: match[0].length,
|
||||
});
|
||||
match = regexp.exec(line);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow {
|
||||
const { line, timestamp } = entry;
|
||||
const { labels } = stream;
|
||||
@ -45,16 +26,15 @@ export function processEntry(entry: { line: string; timestamp: string }, stream)
|
||||
const time = moment(timestamp);
|
||||
const timeFromNow = time.fromNow();
|
||||
const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
|
||||
const searchMatches = getSearchMatches(line, stream.search);
|
||||
const logLevel = getLogLevel(line);
|
||||
|
||||
return {
|
||||
key,
|
||||
logLevel,
|
||||
searchMatches,
|
||||
timeFromNow,
|
||||
timeLocal,
|
||||
entry: line,
|
||||
searchWords: [stream.search],
|
||||
timestamp: timestamp,
|
||||
};
|
||||
}
|
||||
|
@ -13,6 +13,13 @@ export interface Query {
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface TextMatch {
|
||||
text: string;
|
||||
start: number;
|
||||
length: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ExploreState {
|
||||
datasource: any;
|
||||
datasourceError: any;
|
||||
|
@ -66,7 +66,6 @@
|
||||
|
||||
.typeahead-item__selected {
|
||||
background-color: $typeahead-selected-bg;
|
||||
color: $typeahead-selected-color;
|
||||
|
||||
.typeahead-item-hint {
|
||||
font-size: $font-size-xs;
|
||||
@ -74,6 +73,14 @@
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.typeahead-match {
|
||||
color: $typeahead-selected-color;
|
||||
border-bottom: 1px solid $typeahead-selected-color;
|
||||
// Undoing mark styling
|
||||
padding: inherit;
|
||||
background: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* SYNTAX */
|
||||
|
Loading…
Reference in New Issue
Block a user