From efe62acd80245003e31141b7a76602a280aba82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Fri, 11 Dec 2020 11:25:45 +0100 Subject: [PATCH] [Logs UI] Add helper hooks with search strategy request cancellation (#83906) --- x-pack/plugins/infra/.storybook/main.js | 7 +- .../common/search_strategies/common/errors.ts | 22 +- .../components/centered_flyout_body.tsx | 25 ++ .../data_search_error_callout.stories.tsx | 86 +++++++ .../components/data_search_error_callout.tsx | 77 +++++++ .../data_search_progress.stories.tsx | 52 +++++ .../components/data_search_progress.tsx | 45 ++++ .../log_entry_fields_table.tsx | 101 +++++++++ .../log_entry_flyout/log_entry_flyout.tsx | 214 +++++++----------- .../scrollable_log_text_stream_view.tsx | 15 +- .../logs/log_entries/api/fetch_log_entry.ts | 31 --- .../infra/public/containers/logs/log_entry.ts | 62 +++++ .../public/containers/logs/log_flyout.tsx | 81 +++---- .../log_entry_rate/page_results_content.tsx | 41 ++-- .../sections/anomalies/log_entry_example.tsx | 17 +- .../pages/logs/stream/page_logs_content.tsx | 28 +-- .../utils/data_search/data_search.stories.mdx | 140 ++++++++++++ .../infra/public/utils/data_search/index.ts | 9 + .../infra/public/utils/data_search/types.ts | 36 +++ .../use_data_search_request.test.tsx | 188 +++++++++++++++ .../data_search/use_data_search_request.ts | 97 ++++++++ ...test_partial_data_search_response.test.tsx | 116 ++++++++++ ...use_latest_partial_data_search_response.ts | 114 ++++++++++ .../infra/public/utils/use_observable.ts | 94 ++++++++ .../log_entry_search_strategy.test.ts | 30 +++ .../services/log_entries/queries/log_entry.ts | 2 +- 26 files changed, 1445 insertions(+), 285 deletions(-) create mode 100644 x-pack/plugins/infra/public/components/centered_flyout_body.tsx create mode 100644 x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx create mode 100644 x-pack/plugins/infra/public/components/data_search_error_callout.tsx create mode 100644 x-pack/plugins/infra/public/components/data_search_progress.stories.tsx create mode 100644 x-pack/plugins/infra/public/components/data_search_progress.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_entry.ts create mode 100644 x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx create mode 100644 x-pack/plugins/infra/public/utils/data_search/index.ts create mode 100644 x-pack/plugins/infra/public/utils/data_search/types.ts create mode 100644 x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx create mode 100644 x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts create mode 100644 x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx create mode 100644 x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts create mode 100644 x-pack/plugins/infra/public/utils/use_observable.ts diff --git a/x-pack/plugins/infra/.storybook/main.js b/x-pack/plugins/infra/.storybook/main.js index 1818aa44a939..95e8ab8535a4 100644 --- a/x-pack/plugins/infra/.storybook/main.js +++ b/x-pack/plugins/infra/.storybook/main.js @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -module.exports = require('@kbn/storybook').defaultConfig; +const defaultConfig = require('@kbn/storybook').defaultConfig; + +module.exports = { + ...defaultConfig, + stories: ['../**/*.stories.mdx', ...defaultConfig.stories], +}; diff --git a/x-pack/plugins/infra/common/search_strategies/common/errors.ts b/x-pack/plugins/infra/common/search_strategies/common/errors.ts index 4f7954c09c48..3a08564f3494 100644 --- a/x-pack/plugins/infra/common/search_strategies/common/errors.ts +++ b/x-pack/plugins/infra/common/search_strategies/common/errors.ts @@ -6,12 +6,22 @@ import * as rt from 'io-ts'; -const genericErrorRT = rt.type({ +const abortedRequestSearchStrategyErrorRT = rt.type({ + type: rt.literal('aborted'), +}); + +export type AbortedRequestSearchStrategyError = rt.TypeOf< + typeof abortedRequestSearchStrategyErrorRT +>; + +const genericSearchStrategyErrorRT = rt.type({ type: rt.literal('generic'), message: rt.string, }); -const shardFailureErrorRT = rt.type({ +export type GenericSearchStrategyError = rt.TypeOf; + +const shardFailureSearchStrategyErrorRT = rt.type({ type: rt.literal('shardFailure'), shardInfo: rt.type({ shard: rt.number, @@ -21,6 +31,12 @@ const shardFailureErrorRT = rt.type({ message: rt.string, }); -export const searchStrategyErrorRT = rt.union([genericErrorRT, shardFailureErrorRT]); +export type ShardFailureSearchStrategyError = rt.TypeOf; + +export const searchStrategyErrorRT = rt.union([ + abortedRequestSearchStrategyErrorRT, + genericSearchStrategyErrorRT, + shardFailureSearchStrategyErrorRT, +]); export type SearchStrategyError = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/components/centered_flyout_body.tsx b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx new file mode 100644 index 000000000000..ec762610f36c --- /dev/null +++ b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx @@ -0,0 +1,25 @@ +/* + * 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 { EuiFlyoutBody } from '@elastic/eui'; +import { euiStyled } from '../../../observability/public'; + +export const CenteredEuiFlyoutBody = euiStyled(EuiFlyoutBody)` + & .euiFlyoutBody__overflow { + display: flex; + flex-direction: column; + } + + & .euiFlyoutBody__overflowContent { + align-items: center; + align-self: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + overflow: hidden; + } +`; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx new file mode 100644 index 000000000000..4e46e5fdd3f4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx @@ -0,0 +1,86 @@ +/* + * 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 { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchErrorCallout } from './data_search_error_callout'; + +export default { + title: 'infra/dataSearch/DataSearchErrorCallout', + decorators: [ + (wrappedStory) => ( + +
{wrappedStory()}
+
+ ), + ], + parameters: { + layout: 'padded', + }, + argTypes: { + errors: { + control: { + type: 'object', + }, + }, + }, +} as Meta; + +type DataSearchErrorCalloutProps = PropsOf; + +const DataSearchErrorCalloutTemplate: Story = (args) => ( + +); + +const commonArgs = { + title: 'Failed to load data', + errors: [ + { + type: 'generic' as const, + message: 'A generic error message', + }, + { + type: 'shardFailure' as const, + shardInfo: { + index: 'filebeat-7.9.3-2020.12.01-000003', + node: 'a45hJUm3Tba4U8MkvkCU_g', + shard: 0, + }, + message: 'No mapping found for [@timestamp] in order to sort on', + }, + ], +}; + +export const ErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCallout.args = { + ...commonArgs, +}; + +export const ErrorCalloutWithRetry = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCalloutWithRetry.args = { + ...commonArgs, +}; +ErrorCalloutWithRetry.argTypes = { + onRetry: { action: 'retrying' }, +}; + +export const AbortedErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +AbortedErrorCallout.args = { + ...commonArgs, + errors: [ + { + type: 'aborted', + }, + ], +}; +AbortedErrorCallout.argTypes = { + onRetry: { action: 'retrying' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx new file mode 100644 index 000000000000..a0ed3bed9507 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx @@ -0,0 +1,77 @@ +/* + * 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 { EuiButton, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { + AbortedRequestSearchStrategyError, + GenericSearchStrategyError, + SearchStrategyError, + ShardFailureSearchStrategyError, +} from '../../common/search_strategies/common/errors'; + +export const DataSearchErrorCallout: React.FC<{ + title: React.ReactNode; + errors: SearchStrategyError[]; + onRetry?: () => void; +}> = ({ errors, onRetry, title }) => { + const calloutColor = errors.some((error) => error.type !== 'aborted') ? 'danger' : 'warning'; + + return ( + + {errors?.map((error, errorIndex) => ( + + ))} + {onRetry ? ( + + + + ) : null} + + ); +}; + +const DataSearchErrorMessage: React.FC<{ error: SearchStrategyError }> = ({ error }) => { + if (error.type === 'aborted') { + return ; + } else if (error.type === 'shardFailure') { + return ; + } else { + return ; + } +}; + +const AbortedRequestErrorMessage: React.FC<{ + error?: AbortedRequestSearchStrategyError; +}> = ({}) => ( + +); + +const GenericErrorMessage: React.FC<{ error: GenericSearchStrategyError }> = ({ error }) => ( +

{error.message ?? `${error}`}

+); + +const ShardFailureErrorMessage: React.FC<{ error: ShardFailureSearchStrategyError }> = ({ + error, +}) => ( + +); diff --git a/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx new file mode 100644 index 000000000000..d5293a728230 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx @@ -0,0 +1,52 @@ +/* + * 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 { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchProgress } from './data_search_progress'; + +export default { + title: 'infra/dataSearch/DataSearchProgress', + decorators: [ + (wrappedStory) => ( + +
{wrappedStory()}
+
+ ), + ], + parameters: { + layout: 'padded', + }, +} as Meta; + +type DataSearchProgressProps = PropsOf; + +const DataSearchProgressTemplate: Story = (args) => ( + +); + +export const UndeterminedProgress = DataSearchProgressTemplate.bind({}); + +export const DeterminedProgress = DataSearchProgressTemplate.bind({}); + +DeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; + +export const CancelableDeterminedProgress = DataSearchProgressTemplate.bind({}); + +CancelableDeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; +CancelableDeterminedProgress.argTypes = { + onCancel: { action: 'canceled' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_progress.tsx b/x-pack/plugins/infra/public/components/data_search_progress.tsx new file mode 100644 index 000000000000..bf699ac97623 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +export const DataSearchProgress: React.FC<{ + label?: React.ReactNode; + maxValue?: number; + onCancel?: () => void; + value?: number; +}> = ({ label, maxValue, onCancel, value }) => { + const valueText = useMemo( + () => + Number.isFinite(maxValue) && Number.isFinite(value) ? `${value} / ${maxValue}` : undefined, + [value, maxValue] + ); + + return ( + + + + + {onCancel ? ( + + + + ) : null} + + ); +}; + +const cancelButtonLabel = i18n.translate('xpack.infra.dataSearch.cancelButtonLabel', { + defaultMessage: 'Cancel request', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx new file mode 100644 index 000000000000..44e9902e0413 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx @@ -0,0 +1,101 @@ +/* + * 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 { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { + LogEntry, + LogEntryField, +} from '../../../../common/search_strategies/log_entries/log_entry'; +import { TimeKey } from '../../../../common/time'; +import { FieldValue } from '../log_text_stream/field_value'; + +export const LogEntryFieldsTable: React.FC<{ + logEntry: LogEntry; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; +}> = ({ logEntry, onSetFieldFilter }) => { + const createSetFilterHandler = useMemo( + () => + onSetFieldFilter + ? (field: LogEntryField) => () => { + onSetFieldFilter?.(`${field.field}:"${field.value}"`, logEntry.id, logEntry.key); + } + : undefined, + [logEntry, onSetFieldFilter] + ); + + const columns = useMemo>>( + () => [ + { + field: 'field', + name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { + defaultMessage: 'Field', + }), + sortable: true, + }, + { + actions: [ + { + type: 'icon', + icon: 'filter', + name: setFilterButtonLabel, + description: setFilterButtonDescription, + available: () => !!createSetFilterHandler, + onClick: (item) => createSetFilterHandler?.(item)(), + }, + ], + }, + { + field: 'value', + name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { + defaultMessage: 'Value', + }), + render: (_name: string, item: LogEntryField) => ( + + ), + }, + ], + [createSetFilterHandler] + ); + + return ( + + columns={columns} + items={logEntry.fields} + search={searchOptions} + sorting={initialSortingOptions} + /> + ); +}; + +const emptyHighlightTerms: string[] = []; + +const initialSortingOptions = { + sort: { + field: 'field', + direction: 'asc' as const, + }, +}; + +const searchOptions = { + box: { + incremental: true, + schema: true, + }, +}; + +const setFilterButtonLabel = i18n.translate('xpack.infra.logFlyout.filterAriaLabel', { + defaultMessage: 'Filter', +}); + +const setFilterButtonDescription = i18n.translate('xpack.infra.logFlyout.setFilterTooltip', { + defaultMessage: 'View event with filter', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index bc0f6dc97017..5684d4068f3b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -5,132 +5,60 @@ */ import { - EuiBasicTableColumn, - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, - EuiInMemoryTable, EuiSpacer, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import React, { useCallback, useMemo } from 'react'; -import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryField, -} from '../../../../common/search_strategies/log_entries/log_entry'; +import React, { useEffect } from 'react'; import { TimeKey } from '../../../../common/time'; -import { InfraLoadingPanel } from '../../loading'; -import { FieldValue } from '../log_text_stream/field_value'; +import { useLogEntry } from '../../../containers/logs/log_entry'; +import { CenteredEuiFlyoutBody } from '../../centered_flyout_body'; +import { DataSearchErrorCallout } from '../../data_search_error_callout'; +import { DataSearchProgress } from '../../data_search_progress'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; +import { LogEntryFieldsTable } from './log_entry_fields_table'; export interface LogEntryFlyoutProps { - flyoutError: string | null; - flyoutItem: LogEntry | null; - setFlyoutVisibility: (visible: boolean) => void; - setFilter: (filter: string, flyoutItemId: string, timeKey?: TimeKey) => void; - loading: boolean; + logEntryId: string | null | undefined; + onCloseFlyout: () => void; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; + sourceId: string | null | undefined; } -const emptyHighlightTerms: string[] = []; - -const initialSortingOptions = { - sort: { - field: 'field', - direction: 'asc' as const, - }, -}; - -const searchOptions = { - box: { - incremental: true, - schema: true, - }, -}; - export const LogEntryFlyout = ({ - flyoutError, - flyoutItem, - loading, - setFlyoutVisibility, - setFilter, + logEntryId, + onCloseFlyout, + onSetFieldFilter, + sourceId, }: LogEntryFlyoutProps) => { - const createFilterHandler = useCallback( - (field: LogEntryField) => () => { - if (!flyoutItem) { - return; - } + const { + cancelRequest: cancelLogEntryRequest, + errors: logEntryErrors, + fetchLogEntry, + isRequestRunning, + loaded: logEntryRequestProgress, + logEntry, + total: logEntryRequestTotal, + } = useLogEntry({ + sourceId, + logEntryId, + }); - const filter = `${field.field}:"${field.value}"`; - const timestampMoment = moment(flyoutItem.key.time); - let target; - - if (timestampMoment.isValid()) { - target = { - time: timestampMoment.valueOf(), - tiebreaker: flyoutItem.key.tiebreaker, - }; - } - - setFilter(filter, flyoutItem.id, target); - }, - [flyoutItem, setFilter] - ); - - const closeFlyout = useCallback(() => setFlyoutVisibility(false), [setFlyoutVisibility]); - - const columns = useMemo>>( - () => [ - { - field: 'field', - name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { - defaultMessage: 'Field', - }), - sortable: true, - }, - { - field: 'value', - name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { - defaultMessage: 'Value', - }), - render: (_name: string, item: LogEntryField) => ( - - - - - - - ), - }, - ], - [createFilterHandler] - ); + useEffect(() => { + if (sourceId && logEntryId) { + fetchLogEntry(); + } + }, [fetchLogEntry, sourceId, logEntryId]); return ( - + @@ -140,12 +68,12 @@ export const LogEntryFlyout = ({ defaultMessage="Details for log entry {logEntryId}" id="xpack.infra.logFlyout.flyoutTitle" values={{ - logEntryId: flyoutItem ? {flyoutItem.id} : '', + logEntryId: logEntryId ? {logEntryId} : '', }} /> - {flyoutItem ? ( + {logEntry ? ( <> @@ -153,7 +81,7 @@ export const LogEntryFlyout = ({ id="xpack.infra.logFlyout.flyoutSubTitle" defaultMessage="From index {indexName}" values={{ - indexName: {flyoutItem.index}, + indexName: {logEntry.index}, }} /> @@ -161,40 +89,54 @@ export const LogEntryFlyout = ({ ) : null} - {flyoutItem !== null ? : null} + {logEntry ? : null} - - {loading ? ( - - +
+ - - ) : flyoutItem ? ( - - columns={columns} - items={flyoutItem.fields} - search={searchOptions} - sorting={initialSortingOptions} - /> - ) : ( - {flyoutError} - )} - +
+ + ) : logEntry ? ( + 0 ? ( + + ) : undefined + } + > + + + ) : ( + +
+ +
+
+ )}
); }; -export const InfraFlyoutLoadingPanel = euiStyled.div` - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -`; +const loadingProgressMessage = i18n.translate('xpack.infra.logFlyout.loadingMessage', { + defaultMessage: 'Searching log entry in shards', +}); + +const loadingErrorCalloutTitle = i18n.translate('xpack.infra.logFlyout.loadingErrorCalloutTitle', { + defaultMessage: 'Error while searching the log entry', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index ab0f0ac78529..3c86ce3e3252 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -51,8 +51,7 @@ interface ScrollableLogTextStreamViewProps { }) => any; loadNewerItems: () => void; reloadItems: () => void; - setFlyoutItem?: (id: string) => void; - setFlyoutVisibility?: (visible: boolean) => void; + onOpenLogEntryFlyout?: (logEntryId?: string) => void; setContextEntry?: (entry: LogEntry) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; @@ -143,15 +142,14 @@ export class ScrollableLogTextStreamView extends React.PureComponent< lastLoadedTime, updateDateRange, startLiveStreaming, - setFlyoutItem, - setFlyoutVisibility, + onOpenLogEntryFlyout, setContextEntry, } = this.props; const hideScrollbar = this.props.hideScrollbar ?? true; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; - const hasFlyoutAction = !!(setFlyoutItem && setFlyoutVisibility); + const hasFlyoutAction = !!onOpenLogEntryFlyout; const hasContextAction = !!setContextEntry; return ( @@ -305,12 +303,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } private handleOpenFlyout = (id: string) => { - const { setFlyoutItem, setFlyoutVisibility } = this.props; - - if (setFlyoutItem && setFlyoutVisibility) { - setFlyoutItem(id); - setFlyoutVisibility(true); - } + this.props.onOpenLogEntryFlyout?.(id); }; private handleOpenViewLogInContext = (entry: LogEntry) => { diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts deleted file mode 100644 index 764de1d34a3b..000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 { ISearchStart } from '../../../../../../../../src/plugins/data/public'; -import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { - LogEntry, - LogEntrySearchRequestParams, - logEntrySearchRequestParamsRT, - logEntrySearchResponsePayloadRT, - LOG_ENTRY_SEARCH_STRATEGY, -} from '../../../../../common/search_strategies/log_entries/log_entry'; - -export { LogEntry }; - -export const fetchLogEntry = async ( - requestArgs: LogEntrySearchRequestParams, - search: ISearchStart -) => { - const response = await search - .search( - { params: logEntrySearchRequestParamsRT.encode(requestArgs) }, - { strategy: LOG_ENTRY_SEARCH_STRATEGY } - ) - .toPromise(); - - return decodeOrThrow(logEntrySearchResponsePayloadRT)(response.rawResponse); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entry.ts new file mode 100644 index 000000000000..af8618b8be56 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entry.ts @@ -0,0 +1,62 @@ +/* + * 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 { useCallback } from 'react'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + logEntrySearchRequestParamsRT, + logEntrySearchResponsePayloadRT, + LOG_ENTRY_SEARCH_STRATEGY, +} from '../../../common/search_strategies/log_entries/log_entry'; +import { useDataSearch, useLatestPartialDataSearchResponse } from '../../utils/data_search'; + +export const useLogEntry = ({ + sourceId, + logEntryId, +}: { + sourceId: string | null | undefined; + logEntryId: string | null | undefined; +}) => { + const { search: fetchLogEntry, requests$: logEntrySearchRequests$ } = useDataSearch({ + getRequest: useCallback(() => { + return !!logEntryId && !!sourceId + ? { + request: { + params: logEntrySearchRequestParamsRT.encode({ sourceId, logEntryId }), + }, + options: { strategy: LOG_ENTRY_SEARCH_STRATEGY }, + } + : null; + }, [sourceId, logEntryId]), + }); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, + } = useLatestPartialDataSearchResponse( + logEntrySearchRequests$, + null, + decodeLogEntrySearchResponse + ); + + return { + cancelRequest, + errors: latestResponseErrors, + fetchLogEntry, + isRequestRunning, + isResponsePartial, + loaded, + logEntry: latestResponseData ?? null, + total, + }; +}; + +const decodeLogEntrySearchResponse = decodeOrThrow(logEntrySearchResponsePayloadRT); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 121f0e6b651d..7f35af580051 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -6,12 +6,8 @@ import createContainer from 'constate'; import { isString } from 'lodash'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import React, { useCallback, useState } from 'react'; import { UrlStateContainer } from '../../utils/url_state'; -import { useTrackedPromise } from '../../utils/use_tracked_promise'; -import { fetchLogEntry } from './log_entries/api/fetch_log_entry'; -import { useLogSourceContext } from './log_source'; export enum FlyoutVisibility { hidden = 'hidden', @@ -25,97 +21,78 @@ export interface FlyoutOptionsUrlState { } export const useLogFlyout = () => { - const { services } = useKibanaContextForPlugin(); - const { sourceId } = useLogSourceContext(); - const [flyoutVisible, setFlyoutVisibility] = useState(false); - const [flyoutId, setFlyoutId] = useState(null); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [logEntryId, setLogEntryId] = useState(null); const [surroundingLogsId, setSurroundingLogsId] = useState(null); - const [loadFlyoutItemRequest, loadFlyoutItem] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async () => { - if (!flyoutId) { - throw new Error('Failed to load log entry: Id not specified.'); - } - return await fetchLogEntry({ sourceId, logEntryId: flyoutId }, services.data.search); - }, - }, - [sourceId, flyoutId] - ); - - const isLoading = useMemo(() => { - return loadFlyoutItemRequest.state === 'pending'; - }, [loadFlyoutItemRequest.state]); - - useEffect(() => { - if (flyoutId) { - loadFlyoutItem(); + const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + const openFlyout = useCallback((newLogEntryId?: string) => { + if (newLogEntryId) { + setLogEntryId(newLogEntryId); } - }, [loadFlyoutItem, flyoutId]); + setIsFlyoutOpen(true); + }, []); return { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + closeFlyout, + openFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - isLoading, - flyoutItem: - loadFlyoutItemRequest.state === 'resolved' ? loadFlyoutItemRequest.value.data : null, - flyoutError: - loadFlyoutItemRequest.state === 'rejected' ? `${loadFlyoutItemRequest.value}` : null, }; }; export const LogFlyout = createContainer(useLogFlyout); +export const [LogEntryFlyoutProvider, useLogEntryFlyoutContext] = LogFlyout; export const WithFlyoutOptionsUrlState = () => { const { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + openFlyout, + closeFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - } = useContext(LogFlyout.Context); + } = useLogEntryFlyoutContext(); return ( { if (newUrlState && newUrlState.flyoutId) { - setFlyoutId(newUrlState.flyoutId); + setLogEntryId(newUrlState.flyoutId); } if (newUrlState && newUrlState.surroundingLogsId) { setSurroundingLogsId(newUrlState.surroundingLogsId); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} onInitialize={(initialUrlState) => { if (initialUrlState && initialUrlState.flyoutId) { - setFlyoutId(initialUrlState.flyoutId); + setLogEntryId(initialUrlState.flyoutId); } if (initialUrlState && initialUrlState.surroundingLogsId) { setSurroundingLogsId(initialUrlState.surroundingLogsId); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} /> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index bb0c9196fb0c..c4a464a4cffa 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -7,21 +7,25 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; -import { encode, RisonValue } from 'rison-node'; import { stringify } from 'query-string'; -import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { encode, RisonValue } from 'rison-node'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; +import { TimeKey } from '../../../../common/time'; import { CategoryJobNoticesSection, LogAnalysisJobProblemIndicator, } from '../../../components/logging/log_analysis_job_status'; import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { LogEntryFlyout } from '../../../components/logging/log_entry_flyout'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis/log_analysis_capabilities'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { useLogEntryFlyoutContext } from '../../../containers/logs/log_flyout'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useInterval } from '../../../hooks/use_interval'; import { AnomaliesResults } from './sections/anomalies'; @@ -31,9 +35,6 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; -import { LogEntryFlyout, LogEntryFlyoutProps } from '../../../components/logging/log_entry_flyout'; -import { LogFlyout } from '../../../containers/logs/log_flyout'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -77,6 +78,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { setAutoRefresh, } = useLogAnalysisResultsUrlState(); + const { + closeFlyout: closeLogEntryFlyout, + isFlyoutOpen: isLogEntryFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); + const [queryTimeRange, setQueryTimeRange] = useState<{ value: TimeRange; lastChangedTime: number; @@ -85,8 +92,8 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { lastChangedTime: Date.now(), })); - const linkToLogStream = useCallback( - (filter, id, timeKey) => { + const linkToLogStream = useCallback( + (filter: string, id: string, timeKey?: TimeKey) => { const params = { logPosition: encode({ end: moment(queryTimeRange.value.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), @@ -144,14 +151,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { filteredDatasets: selectedDatasets, }); - const { - flyoutVisible, - setFlyoutVisibility, - flyoutError, - flyoutItem, - isLoading: isFlyoutLoading, - } = useContext(LogFlyout.Context); - const handleQueryTimeRangeChange = useCallback( ({ start: startTime, end: endTime }: { start: string; end: string }) => { setQueryTimeRange({ @@ -305,14 +304,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - - {flyoutVisible ? ( + {isLogEntryFlyoutOpen ? ( ) : null} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index a22648666609..b639cecf676a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useState, useContext } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; @@ -37,7 +37,7 @@ import { } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; import { LogEntryAnomaly } from '../../../../../../common/http_api'; -import { LogFlyout } from '../../../../../containers/logs/log_flyout'; +import { useLogEntryFlyoutContext } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -88,7 +88,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ const setItemIsHovered = useCallback(() => setIsHovered(true), []); const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); - const { setFlyoutVisibility, setFlyoutId } = useContext(LogFlyout.Context); + const { openFlyout: openLogEntryFlyout } = useLogEntryFlyoutContext(); // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -129,8 +129,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ { label: VIEW_DETAILS_LABEL, onClick: () => { - setFlyoutId(id); - setFlyoutVisibility(true); + openLogEntryFlyout(id); }, }, { @@ -144,13 +143,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ href: viewAnomalyInMachineLearningLinkProps.href, }, ]; - }, [ - id, - setFlyoutId, - setFlyoutVisibility, - viewInStreamLinkProps, - viewAnomalyInMachineLearningLinkProps, - ]); + }, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); return ( { const { sourceConfiguration, sourceId } = useLogSourceContext(); const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { - setFlyoutVisibility, - flyoutVisible, - setFlyoutId, surroundingLogsId, setSurroundingLogsId, - flyoutItem, - flyoutError, - isLoading, - } = useContext(LogFlyoutState.Context); + closeFlyout: closeLogEntryFlyout, + openFlyout: openLogEntryFlyout, + isFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { @@ -76,13 +74,12 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { - {flyoutVisible ? ( + {isFlyoutOpen ? ( ) : null} @@ -116,8 +113,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { scale={textScale} target={targetPosition} wrap={textWrap} - setFlyoutItem={setFlyoutId} - setFlyoutVisibility={setFlyoutVisibility} + onOpenLogEntryFlyout={openLogEntryFlyout} setContextEntry={setContextEntry} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} diff --git a/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx new file mode 100644 index 000000000000..a698b806b4cd --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx @@ -0,0 +1,140 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; + + + +# The `data` plugin and `SearchStrategies` + +The search functionality abstraction provided by the `search` service of the +`data` plugin is pretty powerful: + +- The execution of the request is delegated to a search strategy, which is + executed on the Kibana server side. +- Any plugin can register custom search strategies with custom parameters and + response shapes. +- Search requests can be cancelled via an `AbortSignal`. +- Search requests are decoupled from the transport layer. The service will poll + for new results transparently. +- Partial responses can be returned as they become available if the search + takes longer. + +# Working with `data.search.search()` in the Browser + +The following chapters describe a set of React components and hooks that aim to +make it easy to take advantage of these characteristics from client-side React +code. They implement a producer/consumer pattern that decouples the craeation +of search requests from the consumption of the responses. This keeps each +code-path small and encourages the use of reactive processing, which in turn +reduces the risk of race conditions and incorrect assumptions about the +response timing. + +## Issuing new requests + +The main API to issue new requests is the `data.search.search()` function. It +returns an `Observable` representing the stream of partial and final results +without the consumer having to know the underlying transport mechanisms. +Besides receiving a search-strategy-specific parameter object, it supports +selection of the search strategy as well an `AbortSignal` used for request +cancellation. + +The hook `useDataSearch()` is designed to ease the integration between the +`Observable` world and the React world. It uses the function it is given to +derive the parameters to use for the next search request. The request can then +be issued by calling the returned `search()` function. For each new request the +hook emits an object describing the request and its state in the `requests$` +`Observable`. + +```typescript +const { search, requests$ } = useDataSearch({ + getRequest: useCallback((searchTerm: string) => ({ + request: { + params: { + searchTerm + } + } + }), []); +}); +``` + +## Executing requests and consuming the responses + +The response `Observable`s emitted by `data.search.search()` is "cold", so it +won't be executed unless a subscriber subscribes to it. And in order to cleanly +cancel and garbage collect the subscription it should be integrated with the +React component life-cycle. + +The `useLatestPartialDataSearchResponse()` does that in such a way that the +newest response observable is subscribed to and that any previous response +observables are unsubscribed from for proper cancellation if a new request has +been created. This uses RxJS's `switchMap()` operator under the hood. The hook +also makes sure that all observables are unsubscribed from on unmount. + +Since the specific response shape depends on the data strategy used, the hook +takes a projection function, that is responsible for decoding the response in +an appropriate way. + +A request can fail due to various reasons that include servers-side errors, +Elasticsearch shard failures and network failures. The intention is to map all +of them to a common `SearchStrategyError` interface. While the +`useLatestPartialDataSearchResponse()` hook does that for errors emitted +natively by the response `Observable`, it's the responsibility of the +projection function to handle errors that are encoded in the response body, +which includes most server-side errors. Note that errors and partial results in +a response are not mutually exclusive. + +The request status (running, partial, etc), the response +and the errors are turned in to React component state so they can be used in +the usual rendering cycle: + +```typescript +const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, +} = useLatestPartialDataSearchResponse( + requests$, + 'initialValue', + useMemo(() => decodeOrThrow(mySearchStrategyResponsePayloadRT), []), +); +``` + +## Representing the request state to the user + +After the values have been made available to the React rendering process using +the `useLatestPartialDataSearchResponse()` hook, normal component hierarchies +can be used to make the request state and result available to the user. The +following utility components can make that even easier. + +### Undetermined progress + +If `total` and `loaded` are not (yet) known, we can show an undetermined +progress bar. + + + + + +### Known progress + +If `total` and `loaded` are returned by the search strategy, they can be used +to show a progress bar with the option to cancel the request if it takes too +long. + + + + + +### Failed requests + +Assuming the errors are represented as an array of `SearchStrategyError`s in +the `latestResponseErrors` return value, they can be rendered as appropriate +for the respective part of the UI. For many cases a `EuiCallout` is suitable, +so the `DataSearchErrorCallout` can serve as a starting point: + + + + + diff --git a/x-pack/plugins/infra/public/utils/data_search/index.ts b/x-pack/plugins/infra/public/utils/data_search/index.ts new file mode 100644 index 000000000000..c08ab0727fd9 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './types'; +export * from './use_data_search_request'; +export * from './use_latest_partial_data_search_response'; diff --git a/x-pack/plugins/infra/public/utils/data_search/types.ts b/x-pack/plugins/infra/public/utils/data_search/types.ts new file mode 100644 index 000000000000..ba0a4c639dae --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { Observable } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; + +export interface DataSearchRequestDescriptor { + request: Request; + options: ISearchOptions; + response$: Observable>; + abortController: AbortController; +} + +export interface NormalizedKibanaSearchResponse { + total?: number; + loaded?: number; + isRunning: boolean; + isPartial: boolean; + data: ResponseData; + errors: SearchStrategyError[]; +} + +export interface DataSearchResponseDescriptor { + request: Request; + options: ISearchOptions; + response: NormalizedKibanaSearchResponse; + abortController: AbortController; +} diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx new file mode 100644 index 000000000000..87c091f12ad9 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx @@ -0,0 +1,188 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { Observable, of, Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { + DataPublicPluginStart, + IKibanaSearchResponse, + ISearchGeneric, + ISearchStart, +} from '../../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { PluginKibanaContextValue } from '../../hooks/use_kibana'; +import { useDataSearch } from './use_data_search_request'; + +describe('useDataSearch hook', () => { + it('forwards the search function arguments to the getRequest function', async () => { + const dataMock = createDataPluginMock(); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((_firstArgument: string, _secondArgument: string) => null); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.search('first', 'second'); + }); + + expect(getRequest).toHaveBeenLastCalledWith('first', 'second'); + expect(dataMock.search.search).not.toHaveBeenCalled(); + }); + + it('creates search requests with the given params and options', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = of({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + expect(dataMock.search.search).toHaveBeenLastCalledWith( + { + params: { firstArgument: 'first', secondArgument: 'second' }, + }, + { + abortSignal: expect.any(Object), + strategy: 'test-search-strategy', + } + ); + expect(firstRequest).toHaveProperty('abortController', expect.any(Object)); + expect(firstRequest).toHaveProperty('request.params', { + firstArgument: 'first', + secondArgument: 'second', + }); + expect(firstRequest).toHaveProperty('options.strategy', 'test-search-strategy'); + expect(firstRequest).toHaveProperty('response$', expect.any(Observable)); + await expect(firstRequest.response$.toPromise()).resolves.toEqual({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + }); + + it('aborts the request when the response observable looses the last subscriber', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = new Subject(); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + // execute requests$ observable + const firstResponseSubscription = firstRequest.response$.subscribe({ + next: jest.fn(), + }); + + // get the abort signal + const [, firstRequestOptions] = dataMock.search.search.mock.calls[0]; + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(false); + + // unsubscribe + firstResponseSubscription.unsubscribe(); + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(true); + }); +}); + +const createDataPluginMock = () => { + const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { + search: ISearchStart & { search: jest.MockedFunction }; + }; + return dataMock; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts new file mode 100644 index 000000000000..a23f06adc035 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts @@ -0,0 +1,97 @@ +/* + * 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 { useCallback } from 'react'; +import { Subject } from 'rxjs'; +import { map, share, switchMap, tap } from 'rxjs/operators'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import { tapUnsubscribe, useObservable } from '../use_observable'; + +export type DataSearchRequestFactory = ( + ...args: Args +) => + | { + request: Request; + options: ISearchOptions; + } + | null + | undefined; + +export const useDataSearch = < + RequestFactoryArgs extends any[], + Request extends IKibanaSearchRequest, + RawResponse +>({ + getRequest, +}: { + getRequest: DataSearchRequestFactory; +}) => { + const { services } = useKibanaContextForPlugin(); + const request$ = useObservable( + () => new Subject<{ request: Request; options: ISearchOptions }>(), + [] + ); + const requests$ = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequest$]) => currentRequest$), + map(({ request, options }) => { + const abortController = new AbortController(); + let isAbortable = true; + + return { + abortController, + request, + options, + response$: services.data.search + .search>(request, { + abortSignal: abortController.signal, + ...options, + }) + .pipe( + // avoid aborting failed or completed requests + tap({ + error: () => { + isAbortable = false; + }, + complete: () => { + isAbortable = false; + }, + }), + tapUnsubscribe(() => { + if (isAbortable) { + abortController.abort(); + } + }), + share() + ), + }; + }) + ), + [request$] + ); + + const search = useCallback( + (...args: RequestFactoryArgs) => { + const request = getRequest(...args); + + if (request) { + request$.next(request); + } + }, + [getRequest, request$] + ); + + return { + requests$, + search, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx new file mode 100644 index 000000000000..4c336aa1107a --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { Observable, of, Subject } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../../src/plugins/data/public'; +import { DataSearchRequestDescriptor } from './types'; +import { useLatestPartialDataSearchResponse } from './use_latest_partial_data_search_response'; + +describe('useLatestPartialDataSearchResponse hook', () => { + it("subscribes to the latest request's response observable", () => { + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Subject>(), + }; + + const secondRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'secondRequestParam' }, + response$: new Subject>(), + }; + + const requests$ = new Subject< + DataSearchRequestDescriptor, string> + >(); + + const { result } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty('current.latestResponseData', undefined); + + // first request is started + act(() => { + requests$.next(firstRequest); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty('current.latestResponseData', 'initial'); + + // first response of the first request arrives + act(() => { + firstRequest.response$.next({ rawResponse: 'request-1-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-1-response-1' + ); + + // second request is started before the second response of the first request arrives + act(() => { + requests$.next(secondRequest); + secondRequest.response$.next({ rawResponse: 'request-2-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-1' + ); + + // second response of the second request arrives + act(() => { + secondRequest.response$.next({ rawResponse: 'request-2-response-2', isRunning: false }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-2' + ); + }); + + it("unsubscribes from the latest request's response observable on unmount", () => { + const onUnsubscribe = jest.fn(); + + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Observable>(() => { + return onUnsubscribe; + }), + }; + + const requests$ = of, string>>( + firstRequest + ); + + const { unmount } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(onUnsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(onUnsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts new file mode 100644 index 000000000000..71fd96283d0e --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts @@ -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 { useCallback } from 'react'; +import { Observable, of } from 'rxjs'; +import { catchError, map, startWith, switchMap } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../../src/plugins/kibana_utils/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; +import { useLatest, useObservable, useObservableState } from '../use_observable'; +import { DataSearchRequestDescriptor, DataSearchResponseDescriptor } from './types'; + +export const useLatestPartialDataSearchResponse = < + Request extends IKibanaSearchRequest, + RawResponse, + Response, + InitialResponse +>( + requests$: Observable>, + initialResponse: InitialResponse, + projectResponse: (rawResponse: RawResponse) => { data: Response; errors?: SearchStrategyError[] } +) => { + const latestInitialResponse = useLatest(initialResponse); + const latestProjectResponse = useLatest(projectResponse); + + const latestResponse$: Observable< + DataSearchResponseDescriptor + > = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequests$]) => + currentRequests$.pipe( + switchMap(({ abortController, options, request, response$ }) => + response$.pipe( + map((response) => { + const { data, errors = [] } = latestProjectResponse.current(response.rawResponse); + return { + abortController, + options, + request, + response: { + data, + errors, + isPartial: response.isPartial ?? false, + isRunning: response.isRunning ?? false, + loaded: response.loaded, + total: response.total, + }, + }; + }), + startWith({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [], + isPartial: true, + isRunning: true, + loaded: 0, + total: undefined, + }, + }), + catchError((error) => + of({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [ + error instanceof AbortError + ? { + type: 'aborted' as const, + } + : { + type: 'generic' as const, + message: `${error.message ?? error}`, + }, + ], + isPartial: true, + isRunning: false, + loaded: 0, + total: undefined, + }, + }) + ) + ) + ) + ) + ) + ), + [requests$] as const + ); + + const { latestValue } = useObservableState(latestResponse$, undefined); + + const cancelRequest = useCallback(() => { + latestValue?.abortController.abort(); + }, [latestValue]); + + return { + cancelRequest, + isRequestRunning: latestValue?.response.isRunning ?? false, + isResponsePartial: latestValue?.response.isPartial ?? false, + latestResponseData: latestValue?.response.data, + latestResponseErrors: latestValue?.response.errors, + loaded: latestValue?.response.loaded, + total: latestValue?.response.total, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/use_observable.ts b/x-pack/plugins/infra/public/utils/use_observable.ts new file mode 100644 index 000000000000..342aa5aa797b --- /dev/null +++ b/x-pack/plugins/infra/public/utils/use_observable.ts @@ -0,0 +1,94 @@ +/* + * 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, useRef, useState } from 'react'; +import { BehaviorSubject, Observable, PartialObserver, Subscription } from 'rxjs'; + +export const useLatest = (value: Value) => { + const valueRef = useRef(value); + valueRef.current = value; + return valueRef; +}; + +export const useObservable = < + OutputValue, + OutputObservable extends Observable, + InputValues extends Readonly +>( + createObservableOnce: (inputValues: Observable) => OutputObservable, + inputValues: InputValues +) => { + const [inputValues$] = useState(() => new BehaviorSubject(inputValues)); + const [output$] = useState(() => createObservableOnce(inputValues$)); + + useEffect(() => { + inputValues$.next(inputValues); + // `inputValues` can't be statically analyzed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, inputValues); + + return output$; +}; + +export const useObservableState = ( + state$: Observable, + initialState: InitialState | (() => InitialState) +) => { + const [latestValue, setLatestValue] = useState(initialState); + const [latestError, setLatestError] = useState(); + + useSubscription(state$, { + next: setLatestValue, + error: setLatestError, + }); + + return { latestValue, latestError }; +}; + +export const useSubscription = ( + input$: Observable, + { next, error, complete, unsubscribe }: PartialObserver & { unsubscribe?: () => void } +) => { + const latestSubscription = useRef(); + const latestNext = useLatest(next); + const latestError = useLatest(error); + const latestComplete = useLatest(complete); + const latestUnsubscribe = useLatest(unsubscribe); + + useEffect(() => { + const fixedUnsubscribe = latestUnsubscribe.current; + + const subscription = input$.subscribe({ + next: (value) => latestNext.current?.(value), + error: (value) => latestError.current?.(value), + complete: () => latestComplete.current?.(), + }); + + latestSubscription.current = subscription; + + return () => { + subscription.unsubscribe(); + fixedUnsubscribe?.(); + }; + }, [input$, latestNext, latestError, latestComplete, latestUnsubscribe]); + + return latestSubscription.current; +}; + +export const tapUnsubscribe = (onUnsubscribe: () => void) => (source$: Observable) => { + return new Observable((subscriber) => { + const subscription = source$.subscribe({ + next: (value) => subscriber.next(value), + error: (error) => subscriber.error(error), + complete: () => subscriber.complete(), + }); + + return () => { + onUnsubscribe(); + subscription.unsubscribe(); + }; + }); +}; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 044cea3899ba..38626675f5ae 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -164,6 +164,35 @@ describe('LogEntry search strategy', () => { await expect(response.toPromise()).rejects.toThrowError(ResponseError); }); + + it('forwards cancellation to the underlying search strategy', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntrySearchStrategy = logEntrySearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + const requestId = logEntrySearchRequestStateRT.encode({ + esRequestId: 'ASYNC_REQUEST_ID', + }); + + await logEntrySearchStrategy.cancel?.(requestId, {}, mockDependencies); + + expect(esSearchStrategyMock.cancel).toHaveBeenCalled(); + }); }); const createSourceConfigurationMock = () => ({ @@ -208,6 +237,7 @@ const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({ return of(esSearchResponse); } }), + cancel: jest.fn().mockResolvedValue(undefined), }); const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({ diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 880a48fd5b8f..dac97479d4b0 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -17,7 +17,7 @@ export const createGetLogEntryQuery = ( logEntryId: string, timestampField: string, tiebreakerField: string -): RequestParams.Search> => ({ +): RequestParams.AsyncSearchSubmit> => ({ index: logEntryIndex, terminate_after: 1, track_scores: false,