Add search filter highlighting in query results
parent
28d20a38f6
commit
4a23608dfd
|
@ -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
|
||||
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -25,4 +25,8 @@
|
|||
color: $c-laser;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-message--match {
|
||||
color: $c-laser;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -223,6 +223,7 @@ class LogsPage extends Component<Props, State> {
|
|||
onChooseCustomTime={this.handleChooseCustomTime}
|
||||
onExpandMessage={this.handleExpandMessage}
|
||||
notify={notify}
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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})(.*)`)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue