From 5e267208de9a59cdd4abde793336820f9281ee68 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Wed, 24 Jul 2019 08:56:39 +0100 Subject: [PATCH] [Logs UI] Allow for jumping to the previous and next highlight (#40010) This PR adds buttons to the highlighting popover that allow for jumping to the previous and next highlights. The intended user experience should be close to the "find" experience of many text editors. --- .../plugins/infra/common/graphql/types.ts | 7 +- .../plugins/infra/common/time/time_key.ts | 4 + .../logging/log_highlights_menu.tsx | 42 +- .../logging/log_text_stream/highlighting.tsx | 23 +- .../log_entry_field_column.test.tsx | 2 + .../log_entry_field_column.tsx | 8 +- .../log_entry_message_column.tsx | 24 +- .../logging/log_text_stream/log_entry_row.tsx | 4 + .../scrollable_log_text_stream_view.tsx | 8 +- .../logs/log_highlights/data_fetching.tsx | 114 + .../logs/log_highlights/log_highlights.tsx | 126 +- .../logs/log_highlights/next_and_previous.tsx | 105 + .../log_highlights/redux_bridge_setters.tsx | 29 + .../logs/log_highlights/redux_bridges.tsx | 25 +- .../containers/logs/with_stream_items.ts | 5 +- .../infra/public/graphql/introspection.json | 24 +- .../plugins/infra/public/graphql/types.ts | 7 +- .../public/pages/logs/page_logs_content.tsx | 2 + .../infra/public/pages/logs/page_toolbar.tsx | 16 +- .../infra/public/utils/log_entry/log_entry.ts | 9 +- .../plugins/infra/public/utils/styles.ts | 53 +- .../server/graphql/log_entries/resolvers.ts | 2 +- .../server/graphql/log_entries/schema.gql.ts | 6 + .../plugins/infra/server/graphql/types.ts | 7 +- .../log_entries/kibana_log_entries_adapter.ts | 6 +- .../log_entries_domain/log_entries_domain.ts | 46 +- .../test/api_integration/apis/infra/index.js | 1 + .../apis/infra/log_entry_highlights.ts | 285 + .../infra/simple_logs/data.json.gz | Bin 0 -> 823 bytes .../infra/simple_logs/mappings.json | 5449 +++++++++++++++++ 30 files changed, 6308 insertions(+), 131 deletions(-) create mode 100644 x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/data_fetching.tsx create mode 100644 x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx create mode 100644 x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridge_setters.tsx create mode 100644 x-pack/test/api_integration/apis/infra/log_entry_highlights.ts create mode 100644 x-pack/test/functional/es_archives/infra/simple_logs/data.json.gz create mode 100644 x-pack/test/functional/es_archives/infra/simple_logs/mappings.json diff --git a/x-pack/legacy/plugins/infra/common/graphql/types.ts b/x-pack/legacy/plugins/infra/common/graphql/types.ts index c93d51e1efc83..2da829dbf2936 100644 --- a/x-pack/legacy/plugins/infra/common/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/common/graphql/types.ts @@ -334,9 +334,14 @@ export interface InfraTimeKeyInput { tiebreaker: number; } - +/** A highlighting definition */ export interface InfraLogEntryHighlightInput { + /** The query to highlight by */ query: string; + /** The number of highlighted documents to include beyond the beginning of the interval */ + countBefore: number; + /** The number of highlighted documents to include beyond the end of the interval */ + countAfter: number; } export interface InfraTimerangeInput { diff --git a/x-pack/legacy/plugins/infra/common/time/time_key.ts b/x-pack/legacy/plugins/infra/common/time/time_key.ts index 33cb88e8838f5..dca64dacfcb21 100644 --- a/x-pack/legacy/plugins/infra/common/time/time_key.ts +++ b/x-pack/legacy/plugins/infra/common/time/time_key.ts @@ -13,6 +13,10 @@ export interface TimeKey { gid?: string; } +export interface UniqueTimeKey extends TimeKey { + gid: string; +} + export type Comparator = (firstValue: any, secondValue: any) => number; export const isTimeKey = (value: any): value is TimeKey => diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_highlights_menu.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_highlights_menu.tsx index 0582df7f55bb8..6fcb1779cba0c 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_highlights_menu.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_highlights_menu.tsx @@ -25,12 +25,20 @@ interface LogHighlightsMenuProps { onChange: (highlightTerms: string[]) => void; isLoading: boolean; activeHighlights: boolean; + hasPreviousHighlight: boolean; + hasNextHighlight: boolean; + goToPreviousHighlight: () => void; + goToNextHighlight: () => void; } export const LogHighlightsMenu: React.FC = ({ onChange, isLoading, activeHighlights, + hasPreviousHighlight, + goToPreviousHighlight, + hasNextHighlight, + goToNextHighlight, }) => { const { isVisible: isPopoverOpen, @@ -62,7 +70,6 @@ export const LogHighlightsMenu: React.FC = ({ {activeHighlights ? : null} ); - return ( = ({ aria-label={termsFieldLabel} /> + + + + + + + chooseLightOrDarkColor( + props.theme.eui.euiColorAccent, + props.theme.eui.euiColorEmptyShade, + props.theme.eui.euiColorDarkestShade + )}; + background-color: ${props => props.theme.eui.euiColorAccent}; + outline: 1px solid ${props => props.theme.eui.euiColorAccent}; + }; +`; export const HighlightMarker = euiStyled.mark` + color: ${props => + chooseLightOrDarkColor( + tintOrShade(props.theme.eui.euiTextColor, props.theme.eui.euiColorAccent, 0.7, 0.5), + props.theme.eui.euiColorEmptyShade, + props.theme.eui.euiColorDarkestShade + )}; background-color: ${props => tintOrShade(props.theme.eui.euiTextColor, props.theme.eui.euiColorAccent, 0.7, 0.5)}; + outline: 1px solid ${props => + tintOrShade(props.theme.eui.euiTextColor, props.theme.eui.euiColorAccent, 0.7, 0.5)}; + }; `; export const highlightFieldValue = ( diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index e77f2930535f7..ac6d35239c21f 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -23,6 +23,7 @@ describe('LogEntryFieldColumn', () => { { = ({ columnValue, highlights: [firstHighlight], // we only support one highlight for now + isActiveHighlight, isHighlighted, isHovered, isWrapped, @@ -42,7 +44,7 @@ export const LogEntryFieldColumn: React.FunctionComponent ))} @@ -51,7 +53,7 @@ export const LogEntryFieldColumn: React.FunctionComponent( - ({ columnValue, highlights, isHighlighted, isHovered, isWrapped }) => { + ({ columnValue, highlights, isActiveHighlight, isHighlighted, isHovered, isWrapped }) => { const message = useMemo( () => isMessageColumn(columnValue) - ? formatMessageSegments(columnValue.message, highlights) + ? formatMessageSegments(columnValue.message, highlights, isActiveHighlight) : null, - [columnValue, highlights] + [columnValue, highlights, isActiveHighlight] ); return ( @@ -75,23 +76,30 @@ const MessageColumnContent = LogEntryColumnContent.extend.attrs<{ const formatMessageSegments = ( messageSegments: LogEntryMessageSegment[], - highlights: LogEntryHighlightColumn[] + highlights: LogEntryHighlightColumn[], + isActiveHighlight: boolean ) => messageSegments.map((messageSegment, index) => formatMessageSegment( messageSegment, highlights.map(highlight => isHighlightMessageColumn(highlight) ? highlight.message[index].highlights : [] - ) + ), + isActiveHighlight ) ); const formatMessageSegment = ( messageSegment: LogEntryMessageSegment, - [firstHighlight = []]: string[][] // we only support one highlight for now + [firstHighlight = []]: string[][], // we only support one highlight for now + isActiveHighlight: boolean ): React.ReactNode => { if (isFieldSegment(messageSegment)) { - return highlightFieldValue(messageSegment.value, firstHighlight, HighlightMarker); + return highlightFieldValue( + messageSegment.value, + firstHighlight, + isActiveHighlight ? ActiveHighlightMarker : HighlightMarker + ); } else if (isConstantSegment(messageSegment)) { return messageSegment.constant; } diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 7d91a948ff2d2..ce3c0cf0f5a22 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -33,6 +33,7 @@ interface LogEntryRowProps { columnConfigurations: LogColumnConfiguration[]; columnWidths: LogEntryColumnWidths; highlights: LogEntryHighlight[]; + isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; openFlyoutWithItem: (id: string) => void; @@ -45,6 +46,7 @@ export const LogEntryRow = ({ columnConfigurations, columnWidths, highlights, + isActiveHighlight, isHighlighted, logEntry, openFlyoutWithItem, @@ -144,6 +146,7 @@ export const LogEntryRow = ({ columnValue={column} highlights={highlightsByColumnId[column.columnId] || []} isHighlighted={isHighlighted} + isActiveHighlight={isActiveHighlight} isHovered={isHovered} isWrapped={wrap} /> @@ -164,6 +167,7 @@ export const LogEntryRow = ({ void; intl: InjectedIntl; highlightedItem: string | null; + currentHighlightKey: UniqueTimeKey | null; } interface ScrollableLogTextStreamViewState { @@ -97,6 +98,7 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< public render() { const { columnConfigurations, + currentHighlightKey, hasMoreAfterEnd, hasMoreBeforeStart, highlightedItem, @@ -187,6 +189,10 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< boundingBoxRef={itemMeasureRef} logEntry={item.logEntry} highlights={item.highlights} + isActiveHighlight={ + !!currentHighlightKey && + currentHighlightKey.gid === item.logEntry.gid + } scale={scale} wrap={wrap} isHighlighted={ diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/data_fetching.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/data_fetching.tsx new file mode 100644 index 0000000000000..2efe6bbb5bd6a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/data_fetching.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useMemo, useState } from 'react'; + +import { getNextTimeKey, getPreviousTimeKey, TimeKey } from '../../../../common/time'; +import { LogEntryHighlightsQuery } from '../../../graphql/types'; +import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; +import { LogEntryHighlightsMap } from '../../../utils/log_entry'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { logEntryHighlightsQuery } from './log_highlights.gql_query'; + +export type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; + +export const useHighlightsFetcher = ( + sourceId: string, + sourceVersion: string | undefined, + startKey: TimeKey | null, + endKey: TimeKey | null, + filterQuery: string | null, + highlightTerms: string[] +) => { + const apolloClient = useApolloClient(); + const [logEntryHighlights, setLogEntryHighlights] = useState( + undefined + ); + const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async () => { + if (!apolloClient) { + throw new DependencyError('Failed to load source: No apollo client available.'); + } + if (!startKey || !endKey || !highlightTerms.length) { + throw new Error(); + } + + return await apolloClient.query< + LogEntryHighlightsQuery.Query, + LogEntryHighlightsQuery.Variables + >({ + fetchPolicy: 'no-cache', + query: logEntryHighlightsQuery, + variables: { + sourceId, + startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive + endKey: getNextTimeKey(endKey), // interval boundaries are exclusive + filterQuery, + highlights: [ + { + query: JSON.stringify({ + multi_match: { query: highlightTerms[0], type: 'phrase', lenient: true }, + }), + countBefore: 1, + countAfter: 1, + }, + ], + }, + }); + }, + onResolve: response => { + setLogEntryHighlights(response.data.source.logEntryHighlights); + }, + }, + [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] + ); + + useEffect(() => { + setLogEntryHighlights(undefined); + }, [highlightTerms]); + + useEffect(() => { + if ( + highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && + startKey && + endKey + ) { + loadLogEntryHighlights(); + } else { + setLogEntryHighlights(undefined); + } + }, [highlightTerms, startKey, endKey, filterQuery, sourceVersion]); + + const logEntryHighlightsById = useMemo( + () => + logEntryHighlights + ? logEntryHighlights.reduce( + (accumulatedLogEntryHighlightsById, { entries }) => { + return entries.reduce( + (singleHighlightLogEntriesById, entry) => { + const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; + return { + ...singleHighlightLogEntriesById, + [entry.gid]: [...highlightsForId, entry], + }; + }, + accumulatedLogEntryHighlightsById + ); + }, + {} + ) + : {}, + [logEntryHighlights] + ); + + return { + logEntryHighlights, + logEntryHighlightsById, + loadLogEntryHighlightsRequest, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx index a6bf421536dbc..5c7bdad139b04 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/log_highlights.tsx @@ -5,16 +5,11 @@ */ import createContainer from 'constate-latest'; -import { useEffect, useState, useMemo } from 'react'; +import { useState } from 'react'; -import { TimeKey, getPreviousTimeKey, getNextTimeKey } from '../../../../common/time'; -import { LogEntryHighlightsQuery } from '../../../graphql/types'; -import { DependencyError, useApolloClient } from '../../../utils/apollo_context'; -import { LogEntryHighlightsMap } from '../../../utils/log_entry'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { logEntryHighlightsQuery } from './log_highlights.gql_query'; - -type LogEntryHighlights = LogEntryHighlightsQuery.Query['source']['logEntryHighlights']; +import { useHighlightsFetcher } from './data_fetching'; +import { useNextAndPrevious } from './next_and_previous'; +import { useReduxBridgeSetters } from './redux_bridge_setters'; export const useLogHighlightsState = ({ sourceId, @@ -24,86 +19,38 @@ export const useLogHighlightsState = ({ sourceVersion: string | undefined; }) => { const [highlightTerms, setHighlightTerms] = useState([]); - const apolloClient = useApolloClient(); - const [logEntryHighlights, setLogEntryHighlights] = useState( - undefined - ); - const [startKey, setStartKey] = useState(null); - const [endKey, setEndKey] = useState(null); - const [filterQuery, setFilterQuery] = useState(null); - - const [loadLogEntryHighlightsRequest, loadLogEntryHighlights] = useTrackedPromise( - { - cancelPreviousOn: 'resolution', - createPromise: async () => { - if (!apolloClient) { - throw new DependencyError('Failed to load source: No apollo client available.'); - } - if (!startKey || !endKey || !highlightTerms.length) { - throw new Error(); - } - return await apolloClient.query< - LogEntryHighlightsQuery.Query, - LogEntryHighlightsQuery.Variables - >({ - fetchPolicy: 'no-cache', - query: logEntryHighlightsQuery, - variables: { - sourceId, - startKey: getPreviousTimeKey(startKey), // interval boundaries are exclusive - endKey: getNextTimeKey(endKey), // interval boundaries are exclusive - filterQuery, - highlights: [ - { - query: JSON.stringify({ - multi_match: { query: highlightTerms[0], type: 'phrase', lenient: true }, - }), - }, - ], - }, - }); - }, - onResolve: response => { - setLogEntryHighlights(response.data.source.logEntryHighlights); - }, - }, - [apolloClient, sourceId, startKey, endKey, filterQuery, highlightTerms] - ); + const { + startKey, + endKey, + filterQuery, + visibleMidpoint, + setStartKey, + setEndKey, + setFilterQuery, + setVisibleMidpoint, + jumpToTarget, + setJumpToTarget, + } = useReduxBridgeSetters(); - useEffect(() => { - if ( - highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length && - startKey && - endKey - ) { - loadLogEntryHighlights(); - } else { - setLogEntryHighlights(undefined); - } - }, [highlightTerms, startKey, endKey, filterQuery, sourceVersion]); + const { + logEntryHighlights, + logEntryHighlightsById, + loadLogEntryHighlightsRequest, + } = useHighlightsFetcher(sourceId, sourceVersion, startKey, endKey, filterQuery, highlightTerms); - const logEntryHighlightsById = useMemo( - () => - logEntryHighlights - ? logEntryHighlights.reduce( - (accumulatedLogEntryHighlightsById, { entries }) => { - return entries.reduce( - (singleHighlightLogEntriesById, entry) => { - const highlightsForId = singleHighlightLogEntriesById[entry.gid] || []; - return { - ...singleHighlightLogEntriesById, - [entry.gid]: [...highlightsForId, entry], - }; - }, - accumulatedLogEntryHighlightsById - ); - }, - {} - ) - : {}, - [logEntryHighlights] - ); + const { + currentHighlightKey, + hasPreviousHighlight, + hasNextHighlight, + goToPreviousHighlight, + goToNextHighlight, + } = useNextAndPrevious({ + visibleMidpoint, + logEntryHighlights, + highlightTerms, + jumpToTarget, + }); return { highlightTerms, @@ -114,6 +61,13 @@ export const useLogHighlightsState = ({ logEntryHighlights, logEntryHighlightsById, loadLogEntryHighlightsRequest, + setVisibleMidpoint, + currentHighlightKey, + hasPreviousHighlight, + hasNextHighlight, + goToPreviousHighlight, + goToNextHighlight, + setJumpToTarget, }; }; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx new file mode 100644 index 0000000000000..6cf602a4a701e --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/next_and_previous.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isNumber } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { TimeKey, UniqueTimeKey } from '../../../../common/time'; +import { + getLogEntryIndexAtTime, + getLogEntryIndexBeforeTime, + getUniqueLogEntryKey, +} from '../../../utils/log_entry'; +import { LogEntryHighlights } from './data_fetching'; + +export const useNextAndPrevious = ({ + highlightTerms, + jumpToTarget, + logEntryHighlights, + visibleMidpoint, +}: { + highlightTerms: string[]; + jumpToTarget: (target: TimeKey) => void; + logEntryHighlights: LogEntryHighlights | undefined; + visibleMidpoint: TimeKey | null; +}) => { + const [currentTimeKey, setCurrentTimeKey] = useState(null); + + const entries = useMemo( + // simplification, because we only support one highlight phrase for now + () => + logEntryHighlights && logEntryHighlights.length > 0 ? logEntryHighlights[0].entries : [], + [logEntryHighlights] + ); + + useEffect(() => { + setCurrentTimeKey(null); + }, [highlightTerms]); + + useEffect(() => { + if (currentTimeKey) { + jumpToTarget(currentTimeKey); + } + }, [currentTimeKey, jumpToTarget]); + + useEffect(() => { + if (currentTimeKey === null && entries.length > 0) { + const initialIndex = visibleMidpoint + ? clampValue(getLogEntryIndexBeforeTime(entries, visibleMidpoint), 0, entries.length - 1) + : 0; + const initialTimeKey = getUniqueLogEntryKey(entries[initialIndex]); + setCurrentTimeKey(initialTimeKey); + } + }, [currentTimeKey, entries, setCurrentTimeKey]); + + const indexOfCurrentTimeKey = useMemo(() => { + if (currentTimeKey && entries.length > 0) { + return getLogEntryIndexAtTime(entries, currentTimeKey); + } else { + return null; + } + }, [currentTimeKey, entries]); + + const hasPreviousHighlight = useMemo( + () => isNumber(indexOfCurrentTimeKey) && indexOfCurrentTimeKey > 0, + [indexOfCurrentTimeKey] + ); + + const hasNextHighlight = useMemo( + () => + entries.length > 0 && + isNumber(indexOfCurrentTimeKey) && + indexOfCurrentTimeKey < entries.length - 1, + [indexOfCurrentTimeKey, entries] + ); + + const goToPreviousHighlight = useCallback(() => { + if (entries.length && isNumber(indexOfCurrentTimeKey)) { + const previousIndex = indexOfCurrentTimeKey - 1; + const entryTimeKey = getUniqueLogEntryKey(entries[previousIndex]); + setCurrentTimeKey(entryTimeKey); + } + }, [indexOfCurrentTimeKey, entries]); + + const goToNextHighlight = useCallback(() => { + if (entries.length > 0 && isNumber(indexOfCurrentTimeKey)) { + const nextIndex = indexOfCurrentTimeKey + 1; + const entryTimeKey = getUniqueLogEntryKey(entries[nextIndex]); + setCurrentTimeKey(entryTimeKey); + } + }, [indexOfCurrentTimeKey, entries]); + + return { + currentHighlightKey: currentTimeKey, + hasPreviousHighlight, + hasNextHighlight, + goToPreviousHighlight, + goToNextHighlight, + }; +}; + +const clampValue = (value: number, minValue: number, maxValue: number) => + Math.min(Math.max(value, minValue), maxValue); diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridge_setters.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridge_setters.tsx new file mode 100644 index 0000000000000..b3254f597dfcf --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridge_setters.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { TimeKey } from '../../../../common/time'; + +export const useReduxBridgeSetters = () => { + const [startKey, setStartKey] = useState(null); + const [endKey, setEndKey] = useState(null); + const [filterQuery, setFilterQuery] = useState(null); + const [visibleMidpoint, setVisibleMidpoint] = useState(null); + const [jumpToTarget, setJumpToTarget] = useState<(target: TimeKey) => void>(() => undefined); + + return { + startKey, + endKey, + filterQuery, + visibleMidpoint, + setStartKey, + setEndKey, + setFilterQuery, + setVisibleMidpoint, + jumpToTarget, + setJumpToTarget, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridges.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridges.tsx index b5f4419d5e0ba..220eaade12fa6 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridges.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_highlights/redux_bridges.tsx @@ -9,11 +9,12 @@ import React, { useEffect, useContext } from 'react'; import { TimeKey } from '../../../../common/time'; import { withLogFilter } from '../with_log_filter'; import { withStreamItems } from '../with_stream_items'; +import { withLogPosition } from '../with_log_position'; import { LogHighlightsState } from './log_highlights'; // Bridges Redux container state with Hooks state. Once state is moved fully from // Redux to Hooks this can be removed. -export const LogHighlightsPositionBridge = withStreamItems( +export const LogHighlightsStreamItemsBridge = withStreamItems( ({ entriesStart, entriesEnd }: { entriesStart: TimeKey | null; entriesEnd: TimeKey | null }) => { const { setStartKey, setEndKey } = useContext(LogHighlightsState.Context); useEffect(() => { @@ -25,6 +26,27 @@ export const LogHighlightsPositionBridge = withStreamItems( } ); +export const LogHighlightsPositionBridge = withLogPosition( + ({ + visibleMidpoint, + jumpToTargetPosition, + }: { + visibleMidpoint: TimeKey | null; + jumpToTargetPosition: (target: TimeKey) => void; + }) => { + const { setJumpToTarget, setVisibleMidpoint } = useContext(LogHighlightsState.Context); + useEffect(() => { + setVisibleMidpoint(visibleMidpoint); + }, [visibleMidpoint]); + + useEffect(() => { + setJumpToTarget(() => jumpToTargetPosition); + }, [jumpToTargetPosition]); + + return null; + } +); + export const LogHighlightsFilterQueryBridge = withLogFilter( ({ serializedFilterQuery }: { serializedFilterQuery: string | null }) => { const { setFilterQuery } = useContext(LogHighlightsState.Context); @@ -39,6 +61,7 @@ export const LogHighlightsFilterQueryBridge = withLogFilter( export const LogHighlightsBridge = ({ indexPattern }: { indexPattern: any }) => ( <> + diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts index 3288253e8ba3a..51fee075b6443 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts @@ -14,6 +14,7 @@ import { PropsOfContainer, RendererFunction } from '../../utils/typed_react'; import { bindPlainActionCreators } from '../../utils/typed_redux'; // deep inporting to avoid a circular import problem import { LogHighlightsState } from './log_highlights/log_highlights'; +import { UniqueTimeKey } from '../../../common/time'; export const withStreamItems = connect( (state: State) => ({ @@ -45,12 +46,13 @@ export const WithStreamItems = withStreamItems( }: WithStreamItemsProps & { children: RendererFunction< WithStreamItemsProps & { + currentHighlightKey: UniqueTimeKey | null; items: StreamItem[]; } >; initializeOnMount: boolean; }) => { - const { logEntryHighlightsById } = useContext(LogHighlightsState.Context); + const { currentHighlightKey, logEntryHighlightsById } = useContext(LogHighlightsState.Context); const items = useMemo( () => props.isReloading && !props.isAutoReloading @@ -69,6 +71,7 @@ export const WithStreamItems = withStreamItems( return children({ ...props, + currentHighlightKey, items, }); } diff --git a/x-pack/legacy/plugins/infra/public/graphql/introspection.json b/x-pack/legacy/plugins/infra/public/graphql/introspection.json index d919353f1cbe3..c937d4f365e62 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/infra/public/graphql/introspection.json @@ -1678,18 +1678,38 @@ { "kind": "INPUT_OBJECT", "name": "InfraLogEntryHighlightInput", - "description": "", + "description": "A highlighting definition", "fields": null, "inputFields": [ { "name": "query", - "description": "", + "description": "The query to highlight by", "type": { "kind": "NON_NULL", "name": null, "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } }, "defaultValue": null + }, + { + "name": "countBefore", + "description": "The number of highlighted documents to include beyond the beginning of the interval", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } + }, + "defaultValue": null + }, + { + "name": "countAfter", + "description": "The number of highlighted documents to include beyond the end of the interval", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } + }, + "defaultValue": null } ], "interfaces": null, diff --git a/x-pack/legacy/plugins/infra/public/graphql/types.ts b/x-pack/legacy/plugins/infra/public/graphql/types.ts index c93d51e1efc83..2da829dbf2936 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/public/graphql/types.ts @@ -334,9 +334,14 @@ export interface InfraTimeKeyInput { tiebreaker: number; } - +/** A highlighting definition */ export interface InfraLogEntryHighlightInput { + /** The query to highlight by */ query: string; + /** The number of highlighted documents to include beyond the beginning of the interval */ + countBefore: number; + /** The number of highlighted documents to include beyond the end of the interval */ + countAfter: number; } export interface InfraTimerangeInput { diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx index aee896079f178..7f4b52604e469 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx @@ -81,6 +81,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { {({ isAutoReloading, jumpToTargetPosition, reportVisiblePositions, targetPosition }) => ( {({ + currentHighlightKey, hasMoreAfterEnd, hasMoreBeforeStart, isLoadingMore, @@ -108,6 +109,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutItem={setFlyoutId} setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} + currentHighlightKey={currentHighlightKey} /> )} diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx index e1538293a3f96..2255cbff3d0cd 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx @@ -40,9 +40,15 @@ export const LogsToolbar = injectI18n(({ intl }) => { const { setSurroundingLogsId } = useContext(LogFlyout.Context); - const { setHighlightTerms, loadLogEntryHighlightsRequest, highlightTerms } = useContext( - LogHighlightsState.Context - ); + const { + setHighlightTerms, + loadLogEntryHighlightsRequest, + highlightTerms, + hasPreviousHighlight, + hasNextHighlight, + goToPreviousHighlight, + goToNextHighlight, + } = useContext(LogHighlightsState.Context); return ( @@ -105,6 +111,10 @@ export const LogsToolbar = injectI18n(({ intl }) => { activeHighlights={ highlightTerms.filter(highlightTerm => highlightTerm.length > 0).length > 0 } + goToPreviousHighlight={goToPreviousHighlight} + goToNextHighlight={goToNextHighlight} + hasPreviousHighlight={hasPreviousHighlight} + hasNextHighlight={hasNextHighlight} /> diff --git a/x-pack/legacy/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/legacy/plugins/infra/public/utils/log_entry/log_entry.ts index b3daec6bd0c75..be6b8c40753ae 100644 --- a/x-pack/legacy/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/legacy/plugins/infra/public/utils/log_entry/log_entry.ts @@ -6,7 +6,7 @@ import { bisector } from 'd3-array'; -import { compareToTimeKey, getIndexAtTimeKey, TimeKey } from '../../../common/time'; +import { compareToTimeKey, getIndexAtTimeKey, TimeKey, UniqueTimeKey } from '../../../common/time'; import { InfraLogEntryFields } from '../../graphql/types'; export type LogEntry = InfraLogEntryFields.Fragment; @@ -20,7 +20,12 @@ export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; export type LogEntryFieldMessageSegment = InfraLogEntryFields.InfraLogMessageFieldSegmentInlineFragment; -export const getLogEntryKey = (entry: LogEntry) => entry.key; +export const getLogEntryKey = (entry: { key: TimeKey }) => entry.key; + +export const getUniqueLogEntryKey = (entry: { gid: string; key: TimeKey }): UniqueTimeKey => ({ + ...entry.key, + gid: entry.gid, +}); const logEntryTimeBisector = bisector(compareToTimeKey(getLogEntryKey)); diff --git a/x-pack/legacy/plugins/infra/public/utils/styles.ts b/x-pack/legacy/plugins/infra/public/utils/styles.ts index 53e804e194232..523f123666f6f 100644 --- a/x-pack/legacy/plugins/infra/public/utils/styles.ts +++ b/x-pack/legacy/plugins/infra/public/utils/styles.ts @@ -6,7 +6,7 @@ import get from 'lodash/fp/get'; import getOr from 'lodash/fp/getOr'; -import { parseToHsl, shade, tint } from 'polished'; +import { getLuminance, parseToHsl, parseToRgb, rgba, shade, tint } from 'polished'; type PropReader = (props: object, defaultValue?: Default) => Prop; @@ -45,7 +45,52 @@ export const tintOrShade = ( tintFraction: number, shadeFraction: number ) => { - return parseToHsl(textColor).lightness > 0.5 - ? shade(1 - shadeFraction, color) - : tint(1 - tintFraction, color); + if (parseToHsl(textColor).lightness > 0.5) { + return shade(1 - shadeFraction, color); + } else { + return tint(1 - tintFraction, color); + } +}; + +export const getContrast = (color1: string, color2: string): number => { + const luminance1 = getLuminance(color1); + const luminance2 = getLuminance(color2); + + return parseFloat( + (luminance1 > luminance2 + ? (luminance1 + 0.05) / (luminance2 + 0.05) + : (luminance2 + 0.05) / (luminance1 + 0.05) + ).toFixed(2) + ); +}; + +export const chooseLightOrDarkColor = ( + backgroundColor: string, + lightColor: string, + darkColor: string +) => { + if (getContrast(backgroundColor, lightColor) > getContrast(backgroundColor, darkColor)) { + return lightColor; + } else { + return darkColor; + } }; + +export const transparentize = (amount: number, color: string): string => { + if (color === 'transparent') { + return color; + } + + const parsedColor = parseToRgb(color); + const alpha: number = + 'alpha' in parsedColor && typeof parsedColor.alpha === 'number' ? parsedColor.alpha : 1; + const colorWithAlpha = { + ...parsedColor, + alpha: clampValue((alpha * 100 - amount * 100) / 100, 0, 1), + }; + + return rgba(colorWithAlpha); +}; + +export const clampValue = (value: number, minValue: number, maxValue: number) => + Math.max(minValue, Math.min(maxValue, value)); diff --git a/x-pack/legacy/plugins/infra/server/graphql/log_entries/resolvers.ts b/x-pack/legacy/plugins/infra/server/graphql/log_entries/resolvers.ts index 05599fbd8026c..31fd19a6e125d 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/log_entries/resolvers.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/log_entries/resolvers.ts @@ -220,7 +220,7 @@ const isFieldSegment = (segment: InfraLogMessageSegment): segment is InfraLogMes const parseHighlightInputs = (highlightInputs: InfraLogEntryHighlightInput[]) => highlightInputs - ? highlightInputs.reduce>( + ? highlightInputs.reduce>( (parsedHighlightInputs, highlightInput) => { const parsedQuery = parseFilterQuery(highlightInput.query); if (parsedQuery) { diff --git a/x-pack/legacy/plugins/infra/server/graphql/log_entries/schema.gql.ts b/x-pack/legacy/plugins/infra/server/graphql/log_entries/schema.gql.ts index 851577ec41c78..0e5a6203519b3 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/log_entries/schema.gql.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/log_entries/schema.gql.ts @@ -72,8 +72,14 @@ export const logEntriesSchema = gql` columns: [InfraLogEntryColumn!]! } + "A highlighting definition" input InfraLogEntryHighlightInput { + "The query to highlight by" query: String! + "The number of highlighted documents to include beyond the beginning of the interval" + countBefore: Int! + "The number of highlighted documents to include beyond the end of the interval" + countAfter: Int! } "A log summary bucket" diff --git a/x-pack/legacy/plugins/infra/server/graphql/types.ts b/x-pack/legacy/plugins/infra/server/graphql/types.ts index b09d93f4338fa..619166b8b8596 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/types.ts @@ -362,9 +362,14 @@ export interface InfraTimeKeyInput { tiebreaker: number; } - +/** A highlighting definition */ export interface InfraLogEntryHighlightInput { + /** The query to highlight by */ query: string; + /** The number of highlighted documents to include beyond the beginning of the interval */ + countBefore: number; + /** The number of highlighted documents to include beyond the end of the interval */ + countAfter: number; } export interface InfraTimerangeInput { diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index 18f5e3b60110d..17f2d850f1217 100644 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/legacy/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -47,7 +47,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { start: TimeKey, direction: 'asc' | 'desc', maxCount: number, - filterQuery: LogEntryQuery, + filterQuery?: LogEntryQuery, highlightQuery?: LogEntryQuery ): Promise { if (maxCount <= 0) { @@ -86,7 +86,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { fields: string[], start: TimeKey, end: TimeKey, - filterQuery: LogEntryQuery, + filterQuery?: LogEntryQuery, highlightQuery?: LogEntryQuery ): Promise { const documents = await this.getLogEntryDocumentsBetween( @@ -110,7 +110,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { start: number, end: number, bucketSize: number, - filterQuery: LogEntryQuery + filterQuery?: LogEntryQuery ): Promise { const bucketIntervalStarts = timeMilliseconds(new Date(start), new Date(end), bucketSize); diff --git a/x-pack/legacy/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/legacy/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 8fea2f06c567a..d4e7c51d59669 100644 --- a/x-pack/legacy/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/legacy/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -134,6 +134,8 @@ export class InfraLogEntriesDomain { endKey: TimeKey, highlights: Array<{ query: JsonObject; + countBefore: number; + countAfter: number; }>, filterQuery?: LogEntryQuery ): Promise { @@ -148,20 +150,42 @@ export class InfraLogEntriesDomain { const query = filterQuery ? { bool: { - must: [filterQuery, highlight.query], + filter: [filterQuery, highlight.query], }, } : highlight.query; - const documents = await this.adapter.getContainedLogEntryDocuments( - request, - configuration, - requiredFields, - startKey, - endKey, - query, - highlight.query - ); - const entries = documents.map( + const [documentsBefore, documents, documentsAfter] = await Promise.all([ + this.adapter.getAdjacentLogEntryDocuments( + request, + configuration, + requiredFields, + startKey, + 'desc', + highlight.countBefore, + query, + highlight.query + ), + this.adapter.getContainedLogEntryDocuments( + request, + configuration, + requiredFields, + startKey, + endKey, + query, + highlight.query + ), + this.adapter.getAdjacentLogEntryDocuments( + request, + configuration, + requiredFields, + endKey, + 'asc', + highlight.countAfter, + query, + highlight.query + ), + ]); + const entries = [...documentsBefore, ...documents, ...documentsAfter].map( convertLogDocumentToEntry( sourceId, configuration.logColumns, diff --git a/x-pack/test/api_integration/apis/infra/index.js b/x-pack/test/api_integration/apis/infra/index.js index 5189029a93a3b..fc03af20a3e6a 100644 --- a/x-pack/test/api_integration/apis/infra/index.js +++ b/x-pack/test/api_integration/apis/infra/index.js @@ -8,6 +8,7 @@ export default function ({ loadTestFile }) { describe('InfraOps Endpoints', () => { loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./log_entries')); + loadTestFile(require.resolve('./log_entry_highlights')); loadTestFile(require.resolve('./log_summary')); loadTestFile(require.resolve('./logs_without_millis')); loadTestFile(require.resolve('./metrics')); diff --git a/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts new file mode 100644 index 0000000000000..4ffdb838aa93d --- /dev/null +++ b/x-pack/test/api_integration/apis/infra/log_entry_highlights.ts @@ -0,0 +1,285 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { ascending, pairs } from 'd3-array'; +import gql from 'graphql-tag'; + +import { sharedFragments } from '../../../../legacy/plugins/infra/common/graphql/shared'; +import { InfraTimeKey } from '../../../../legacy/plugins/infra/public/graphql/types'; +import { KbnTestProvider } from './types'; + +const KEY_BEFORE_START = { + time: new Date('2000-01-01T00:00:00.000Z').valueOf(), + tiebreaker: -1, +}; +const KEY_AFTER_START = { + time: new Date('2000-01-01T00:00:04.000Z').valueOf(), + tiebreaker: -1, +}; +const KEY_BEFORE_END = { + time: new Date('2000-01-01T00:00:06.001Z').valueOf(), + tiebreaker: 0, +}; +const KEY_AFTER_END = { + time: new Date('2000-01-01T00:00:09.001Z').valueOf(), + tiebreaker: 0, +}; + +const logEntryHighlightsTests: KbnTestProvider = ({ getService }) => { + const esArchiver = getService('esArchiver'); + const client = getService('infraOpsGraphQLClient'); + + describe('log highlight apis', () => { + before(() => esArchiver.load('infra/simple_logs')); + after(() => esArchiver.unload('infra/simple_logs')); + + describe('logEntryHighlights', () => { + describe('with the default source', () => { + before(() => esArchiver.load('empty_kibana')); + after(() => esArchiver.unload('empty_kibana')); + + it('should return log highlights in the built-in message column', async () => { + const { + data: { + source: { logEntryHighlights }, + }, + } = await client.query({ + query: logEntryHighlightsQuery, + variables: { + sourceId: 'default', + startKey: KEY_BEFORE_START, + endKey: KEY_AFTER_END, + highlights: [ + { + query: JSON.stringify({ + multi_match: { query: 'message of document 0', type: 'phrase', lenient: true }, + }), + countBefore: 0, + countAfter: 0, + }, + ], + }, + }); + + expect(logEntryHighlights).to.have.length(1); + + const [logEntryHighlightSet] = logEntryHighlights; + expect(logEntryHighlightSet).to.have.property('entries'); + // ten bundles with one highlight each + expect(logEntryHighlightSet.entries).to.have.length(10); + expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); + + for (const logEntryHighlight of logEntryHighlightSet.entries) { + expect(logEntryHighlight.columns).to.have.length(3); + expect(logEntryHighlight.columns[1]).to.have.property('field'); + expect(logEntryHighlight.columns[1]).to.have.property('highlights'); + expect(logEntryHighlight.columns[1].highlights).to.eql([]); + expect(logEntryHighlight.columns[2]).to.have.property('message'); + expect(logEntryHighlight.columns[2].message).to.be.an('array'); + expect(logEntryHighlight.columns[2].message.length).to.be(1); + expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ + 'message', + 'of', + 'document', + '0', + ]); + } + }); + + it('should return log highlights in a field column', async () => { + const { + data: { + source: { logEntryHighlights }, + }, + } = await client.query({ + query: logEntryHighlightsQuery, + variables: { + sourceId: 'default', + startKey: KEY_BEFORE_START, + endKey: KEY_AFTER_END, + highlights: [ + { + query: JSON.stringify({ + multi_match: { + query: 'generate_test_data/simple_logs', + type: 'phrase', + lenient: true, + }, + }), + countBefore: 0, + countAfter: 0, + }, + ], + }, + }); + + expect(logEntryHighlights).to.have.length(1); + + const [logEntryHighlightSet] = logEntryHighlights; + expect(logEntryHighlightSet).to.have.property('entries'); + // ten bundles with five highlights each + expect(logEntryHighlightSet.entries).to.have.length(50); + expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); + + for (const logEntryHighlight of logEntryHighlightSet.entries) { + expect(logEntryHighlight.columns).to.have.length(3); + expect(logEntryHighlight.columns[1]).to.have.property('field'); + expect(logEntryHighlight.columns[1]).to.have.property('highlights'); + expect(logEntryHighlight.columns[1].highlights).to.eql([ + 'generate_test_data/simple_logs', + ]); + expect(logEntryHighlight.columns[2]).to.have.property('message'); + expect(logEntryHighlight.columns[2].message).to.be.an('array'); + expect(logEntryHighlight.columns[2].message.length).to.be(1); + expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([]); + } + }); + + it('should apply the filter query in addition to the highlight query', async () => { + const { + data: { + source: { logEntryHighlights }, + }, + } = await client.query({ + query: logEntryHighlightsQuery, + variables: { + sourceId: 'default', + startKey: KEY_BEFORE_START, + endKey: KEY_AFTER_END, + filterQuery: JSON.stringify({ + multi_match: { query: 'host-a', type: 'phrase', lenient: true }, + }), + highlights: [ + { + query: JSON.stringify({ + multi_match: { query: 'message', type: 'phrase', lenient: true }, + }), + countBefore: 0, + countAfter: 0, + }, + ], + }, + }); + + expect(logEntryHighlights).to.have.length(1); + + const [logEntryHighlightSet] = logEntryHighlights; + expect(logEntryHighlightSet).to.have.property('entries'); + // half of the documenst + expect(logEntryHighlightSet.entries).to.have.length(25); + expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); + + for (const logEntryHighlight of logEntryHighlightSet.entries) { + expect(logEntryHighlight.columns).to.have.length(3); + expect(logEntryHighlight.columns[1]).to.have.property('field'); + expect(logEntryHighlight.columns[1]).to.have.property('highlights'); + expect(logEntryHighlight.columns[1].highlights).to.eql([]); + expect(logEntryHighlight.columns[2]).to.have.property('message'); + expect(logEntryHighlight.columns[2].message).to.be.an('array'); + expect(logEntryHighlight.columns[2].message.length).to.be(1); + expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ + 'message', + 'message', + ]); + } + }); + + it('should return highlights outside of the interval when requested', async () => { + const { + data: { + source: { logEntryHighlights }, + }, + } = await client.query({ + query: logEntryHighlightsQuery, + variables: { + sourceId: 'default', + startKey: KEY_AFTER_START, + endKey: KEY_BEFORE_END, + highlights: [ + { + query: JSON.stringify({ + multi_match: { query: 'message of document 0', type: 'phrase', lenient: true }, + }), + countBefore: 2, + countAfter: 2, + }, + ], + }, + }); + + expect(logEntryHighlights).to.have.length(1); + + const [logEntryHighlightSet] = logEntryHighlights; + expect(logEntryHighlightSet).to.have.property('entries'); + // three bundles with one highlight each plus two beyond each interval boundary + expect(logEntryHighlightSet.entries).to.have.length(3 + 4); + expect(isSorted(ascendingTimeKey)(logEntryHighlightSet.entries)).to.equal(true); + + for (const logEntryHighlight of logEntryHighlightSet.entries) { + expect(logEntryHighlight.columns).to.have.length(3); + expect(logEntryHighlight.columns[1]).to.have.property('field'); + expect(logEntryHighlight.columns[1]).to.have.property('highlights'); + expect(logEntryHighlight.columns[1].highlights).to.eql([]); + expect(logEntryHighlight.columns[2]).to.have.property('message'); + expect(logEntryHighlight.columns[2].message).to.be.an('array'); + expect(logEntryHighlight.columns[2].message.length).to.be(1); + expect(logEntryHighlight.columns[2].message[0].highlights).to.eql([ + 'message', + 'of', + 'document', + '0', + ]); + } + }); + }); + }); + }); +}; + +const logEntryHighlightsQuery = gql` + query LogEntryHighlightsQuery( + $sourceId: ID = "default" + $startKey: InfraTimeKeyInput! + $endKey: InfraTimeKeyInput! + $filterQuery: String + $highlights: [InfraLogEntryHighlightInput!]! + ) { + source(id: $sourceId) { + id + logEntryHighlights( + startKey: $startKey + endKey: $endKey + filterQuery: $filterQuery + highlights: $highlights + ) { + start { + ...InfraTimeKeyFields + } + end { + ...InfraTimeKeyFields + } + entries { + ...InfraLogEntryHighlightFields + } + } + } + } + + ${sharedFragments.InfraTimeKey} + ${sharedFragments.InfraLogEntryHighlightFields} +`; + +// eslint-disable-next-line import/no-default-export +export default logEntryHighlightsTests; + +const isSorted = (comparator: (first: Value, second: Value) => number) => ( + values: Value[] +) => pairs(values, comparator).every(order => order <= 0); + +const ascendingTimeKey = (first: { key: InfraTimeKey }, second: { key: InfraTimeKey }) => + ascending(first.key.time, second.key.time) || + ascending(first.key.tiebreaker, second.key.tiebreaker); diff --git a/x-pack/test/functional/es_archives/infra/simple_logs/data.json.gz b/x-pack/test/functional/es_archives/infra/simple_logs/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..97921551e0c1d89b6b831ee682b814da92d42c04 GIT binary patch literal 823 zcmb2|=3oE==C?P__T4fNVSQlhFPIt|^~srI^DF0)tGrwfX1YD{SLumTytmivu+Pj# zzDIVRvlZ%Kwb*F*Gv(zA8TLSt?e%49bzP5c7VYlXzhmO{tkME!_uohB)=qy{6uiD% z+c+jSe%3=1{VpSY_jjf;bJchLHuL^``|hup^=Iycy#0`M?zivRZyVzOMBnMF$$z~y z^H$d0;^NDnCw12Tm*|~$?*00*7td4o?cHBxIQv^xn>WUuf3y19nW){pGk1S@oBMe4=VSR{zqjW8i_QsNdw&1>bv&zYOMd%rT6X*0 zwA05ke#_i^UK??{*LCrVdoJ(ApS7PYN_`V?)lJ@CZCBl+{MYee|Iehi`!!!qC~!HT z#(%?q!{>7wI{j{ZVXS`M>05dzjsJ$Vf6g*tv-$(~DwB#!H+Xh@m@?sDnf=CnAEx|B zoZPVC(!);PZNEFewyLudW-qTxye#lgQ?>c1iT_G@GhPmD*?Bh-49`wxig@|q2wQjN zJdp!l?doO`KA)E`gjv~aJeV{KsAn@!k1|NlOnEbIBK3R*>QM*j*(q