Add search filter highlighting in query results

pull/4253/head
Delmer Reed 2018-08-20 14:15:24 -04:00
parent 28d20a38f6
commit 4a23608dfd
9 changed files with 361 additions and 6 deletions

View File

@ -8,6 +8,7 @@
### UI Improvements
1. [#4227](https://github.com/influxdata/chronograf/pull/4227): Redesign Cell Editor Overlay for reuse in other parts of application
1. [#4253](https://github.com/influxdata/chronograf/pull/4253): Add search expression highlighting to log lines
### Bug Fixes

View File

@ -39,10 +39,13 @@ import {
SeverityFormat,
SeverityLevelColor,
RowHeightHandler,
Filter,
Operator,
} from 'src/types/logs'
import {INITIAL_LIMIT} from 'src/logs/actions'
interface Props {
filters: Filter[]
data: TableData
isScrolledToTop: boolean
isTruncated: boolean
@ -621,7 +624,10 @@ class LogsTable extends Component<Props, State> {
}
private renderMessage = (formattedValue: string): JSX.Element => {
const {notify} = this.props
const {notify, filters} = this.props
const messageFilters = filters.filter(
f => f.key === 'message' && f.operator === Operator.LIKE
)
if (this.props.isTruncated) {
return (
@ -629,11 +635,18 @@ class LogsTable extends Component<Props, State> {
notify={notify}
formattedValue={formattedValue}
onExpand={this.props.onExpandMessage}
filters={messageFilters}
/>
)
}
return <LogsMessage notify={notify} formattedValue={formattedValue} />
return (
<LogsMessage
notify={notify}
formattedValue={formattedValue}
filters={messageFilters}
/>
)
}
private severityDotStyle = (

View File

@ -4,6 +4,7 @@ import {ClickOutside} from 'src/shared/components/ClickOutside'
import LogsMessage from 'src/logs/components/logs_message/LogsMessage'
import {NotificationAction} from 'src/types'
import {Filter} from 'src/types/logs'
interface State {
expanded: boolean
@ -13,6 +14,7 @@ interface Props {
formattedValue: string | JSX.Element
notify: NotificationAction
onExpand?: () => void
filters: Filter[]
}
export class ExpandableMessage extends Component<Props, State> {
@ -24,17 +26,27 @@ export class ExpandableMessage extends Component<Props, State> {
}
public render() {
const {notify} = this.props
const {notify, filters} = this.props
const formattedValue = `${this.props.formattedValue}`
const trimmedValue = formattedValue.trimLeft()
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div onClick={this.handleClick} className="expandable--message">
<div className="expandable--text">{trimmedValue}</div>
<div className="expandable--text">
<LogsMessage
formattedValue={trimmedValue}
notify={notify}
filters={filters}
/>
</div>
<div className={this.isExpanded}>
{this.closeExpansionButton}
<LogsMessage formattedValue={formattedValue} notify={notify} />
<LogsMessage
formattedValue={formattedValue}
notify={notify}
filters={filters}
/>
</div>
</div>
</ClickOutside>

View File

@ -25,4 +25,8 @@
color: $c-laser;
cursor: pointer;
}
}
.logs-message--match {
color: $c-laser;
}

View File

@ -1,4 +1,5 @@
import React, {PureComponent} from 'react'
import _ from 'lodash'
import CopyToClipboard from 'react-copy-to-clipboard'
@ -6,12 +7,15 @@ import {
notifyCopyToClipboardSuccess,
notifyCopyToClipboardFailed,
} from 'src/shared/copy/notifications'
import {getMatchSections} from 'src/logs/utils/matchSections'
import {NotificationAction} from 'src/types'
import {Filter} from 'src/types/logs'
interface Props {
formattedValue: string
notify: NotificationAction
filters: Filter[]
}
class LogsMessage extends PureComponent<Props> {
@ -20,7 +24,7 @@ class LogsMessage extends PureComponent<Props> {
return (
<div className="logs-message">
{formattedValue}
{this.messageSections}
<CopyToClipboard text={formattedValue} onCopy={this.handleCopyAttempt}>
<div className="logs-message--copy" title="copy to clipboard">
<span className="icon duplicate" />
@ -46,6 +50,22 @@ class LogsMessage extends PureComponent<Props> {
notify(notifyCopyToClipboardFailed(truncatedText, title))
}
}
private get messageSections(): JSX.Element[] | string {
const {filters, formattedValue} = this.props
if (_.isEmpty(filters)) {
return formattedValue
}
const sections = getMatchSections(filters, formattedValue)
return sections.map(s => (
<span key={s.id} className={`logs-message--${s.type}`}>
{s.text}
</span>
))
}
}
export default LogsMessage

View File

@ -223,6 +223,7 @@ class LogsPage extends Component<Props, State> {
onChooseCustomTime={this.handleChooseCustomTime}
onExpandMessage={this.handleExpandMessage}
notify={notify}
filters={filters}
/>
</div>
</div>

View File

@ -0,0 +1,52 @@
import {MatchType, Filter, MatchSection} from 'src/types/logs'
import uuid from 'uuid'
export const getMatchSections = (
filters: Filter[],
text: string
): MatchSection[] => {
if (filters.length === 0) {
return [createSection(MatchType.NONE, text)]
}
try {
const pattern = filtersToPattern(filters)
return sectionOnPattern(pattern, text)
} catch (e) {
console.error('Syntax Error: bad search filter expression')
return [createSection(MatchType.NONE, text)]
}
}
const sectionOnPattern = (pattern: RegExp, text) => {
let sections = []
let remaining = text
for (
let match = remaining.match(pattern);
match !== null;
match = remaining.match(pattern)
) {
remaining = match[match.length - 1]
sections = [
...sections,
createSection(MatchType.NONE, match[1]),
createSection(MatchType.MATCH, match[2]),
]
}
return [...sections, createSection(MatchType.NONE, remaining)]
}
const createSection = (type: MatchType, text: string): MatchSection => ({
id: uuid.v4(),
type,
text,
})
const filtersToPattern = (filters: Filter[]): RegExp => {
const values = filters.map(f => f.value).join('|')
return new RegExp(`^(.*?)(${values})(.*)`)
}

View File

@ -166,3 +166,14 @@ export enum Operator {
EQUAL = '==',
NOT_EQUAL = '!=',
}
export enum MatchType {
NONE = 'no-match',
MATCH = 'match',
}
export interface MatchSection {
id: string
type: MatchType
text: string
}

View File

@ -0,0 +1,241 @@
import {Filter, MatchType} from 'src/types/logs'
import {getMatchSections} from 'src/logs/utils/matchSections'
describe('Logs.matchFilters', () => {
const isUUID = expect.stringMatching(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
)
const filters: Filter[] = [
{
id: '123',
key: 'message',
value: 'term',
operator: '=~',
},
{
id: '123',
key: 'message',
value: 'other term',
operator: '=~',
},
]
it('can match a filter', () => {
const text = 'before term after'
const actual = getMatchSections(filters, text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text: 'before ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'term',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' after',
},
]
expect(actual).toEqual(expected)
})
it('can match multiple filters', () => {
const text = 'other term after term other term'
const actual = getMatchSections(filters, text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text: '',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'other term',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' after ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'term',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'other term',
},
{
id: isUUID,
type: MatchType.NONE,
text: '',
},
]
expect(actual).toEqual(expected)
})
it('can match empty filters', () => {
const text = 'other term after term other term'
const actual = getMatchSections([], text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text,
},
]
expect(actual).toEqual(expected)
})
it('can match termless text', () => {
const text = ' '
const actual = getMatchSections(filters, text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text,
},
]
expect(actual).toEqual(expected)
})
it('can match parenthesized filters', () => {
const text = 'before stuff afterward'
const filter = {
id: '123',
key: 'message',
value: '(stuff)',
operator: '=~',
}
const actual = getMatchSections([...filters, filter], text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text: 'before ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'stuff',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' afterward',
},
]
expect(actual).toEqual(expected)
})
it('can match complex filters', () => {
const text = 'start fluff puff fluff end'
const filter = {
id: '123',
key: 'message',
value: '((fl|p)uff)',
operator: '=~',
}
const actual = getMatchSections([...filters, filter], text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text: 'start ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'fluff',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'puff',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' ',
},
{
id: isUUID,
type: MatchType.MATCH,
text: 'fluff',
},
{
id: isUUID,
type: MatchType.NONE,
text: ' end',
},
]
expect(actual).toEqual(expected)
})
describe('bad filter pattern', () => {
const errorLog = console.error
beforeEach(() => {
console.error = jest.fn(() => {})
})
afterEach(() => {
console.error = errorLog
})
it('can handle bad search expressions', () => {
const text = 'start fluff puff fluff end'
const filter = {
id: '123',
key: 'message',
value: '((fl|puff)',
operator: '=~',
}
const actual = getMatchSections([...filters, filter], text)
const expected = [
{
id: isUUID,
type: MatchType.NONE,
text,
},
]
expect(actual).toEqual(expected)
expect(console.error).toBeCalledWith(
'Syntax Error: bad search filter expression'
)
})
})
})