[Logs UI] Add helper hooks with search strategy request cancellation (#83906)

This commit is contained in:
Felix Stürmer 2020-12-11 11:25:45 +01:00 committed by GitHub
parent e5c7134925
commit efe62acd80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1445 additions and 285 deletions

View file

@ -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],
};

View file

@ -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<typeof genericSearchStrategyErrorRT>;
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<typeof shardFailureSearchStrategyErrorRT>;
export const searchStrategyErrorRT = rt.union([
abortedRequestSearchStrategyErrorRT,
genericSearchStrategyErrorRT,
shardFailureSearchStrategyErrorRT,
]);
export type SearchStrategyError = rt.TypeOf<typeof searchStrategyErrorRT>;

View file

@ -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;
}
`;

View file

@ -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) => (
<EuiThemeProvider>
<div style={{ width: 600 }}>{wrappedStory()}</div>
</EuiThemeProvider>
),
],
parameters: {
layout: 'padded',
},
argTypes: {
errors: {
control: {
type: 'object',
},
},
},
} as Meta;
type DataSearchErrorCalloutProps = PropsOf<typeof DataSearchErrorCallout>;
const DataSearchErrorCalloutTemplate: Story<DataSearchErrorCalloutProps> = (args) => (
<DataSearchErrorCallout {...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' },
};

View file

@ -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 (
<EuiCallOut color={calloutColor} iconType="alert" title={title}>
{errors?.map((error, errorIndex) => (
<DataSearchErrorMessage key={errorIndex} error={error} />
))}
{onRetry ? (
<EuiButton color={calloutColor} size="s" onClick={onRetry}>
<FormattedMessage
id="xpack.infra.dataSearch.loadingErrorRetryButtonLabel"
defaultMessage="Retry"
/>
</EuiButton>
) : null}
</EuiCallOut>
);
};
const DataSearchErrorMessage: React.FC<{ error: SearchStrategyError }> = ({ error }) => {
if (error.type === 'aborted') {
return <AbortedRequestErrorMessage error={error} />;
} else if (error.type === 'shardFailure') {
return <ShardFailureErrorMessage error={error} />;
} else {
return <GenericErrorMessage error={error} />;
}
};
const AbortedRequestErrorMessage: React.FC<{
error?: AbortedRequestSearchStrategyError;
}> = ({}) => (
<FormattedMessage
tagName="p"
id="xpack.infra.dataSearch.abortedRequestErrorMessage"
defaultMessage="The request was aborted."
/>
);
const GenericErrorMessage: React.FC<{ error: GenericSearchStrategyError }> = ({ error }) => (
<p>{error.message ?? `${error}`}</p>
);
const ShardFailureErrorMessage: React.FC<{ error: ShardFailureSearchStrategyError }> = ({
error,
}) => (
<FormattedMessage
tagName="p"
id="xpack.infra.dataSearch.shardFailureErrorMessage"
defaultMessage="Index {indexName}: {errorMessage}"
values={{
indexName: error.shardInfo.index,
errorMessage: error.message,
}}
/>
);

View file

@ -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) => (
<EuiThemeProvider>
<div style={{ width: 400 }}>{wrappedStory()}</div>
</EuiThemeProvider>
),
],
parameters: {
layout: 'padded',
},
} as Meta;
type DataSearchProgressProps = PropsOf<typeof DataSearchProgress>;
const DataSearchProgressTemplate: Story<DataSearchProgressProps> = (args) => (
<DataSearchProgress {...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' },
};

View file

@ -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 (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiProgress label={label} size="s" max={maxValue} value={value} valueText={valueText} />
</EuiFlexItem>
{onCancel ? (
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="danger"
iconType="cross"
onClick={onCancel}
title={cancelButtonLabel}
aria-label={cancelButtonLabel}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
};
const cancelButtonLabel = i18n.translate('xpack.infra.dataSearch.cancelButtonLabel', {
defaultMessage: 'Cancel request',
});

View file

@ -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<Array<EuiBasicTableColumn<LogEntryField>>>(
() => [
{
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) => (
<FieldValue
highlightTerms={emptyHighlightTerms}
isActiveHighlight={false}
value={item.value}
/>
),
},
],
[createSetFilterHandler]
);
return (
<EuiInMemoryTable<LogEntryField>
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',
});

View file

@ -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<Array<EuiBasicTableColumn<LogEntryField>>>(
() => [
{
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) => (
<span>
<EuiToolTip
content={i18n.translate('xpack.infra.logFlyout.setFilterTooltip', {
defaultMessage: 'View event with filter',
})}
>
<EuiButtonIcon
color="text"
iconType="filter"
aria-label={i18n.translate('xpack.infra.logFlyout.filterAriaLabel', {
defaultMessage: 'Filter',
})}
onClick={createFilterHandler(item)}
/>
</EuiToolTip>
<FieldValue
highlightTerms={emptyHighlightTerms}
isActiveHighlight={false}
value={item.value}
/>
</span>
),
},
],
[createFilterHandler]
);
useEffect(() => {
if (sourceId && logEntryId) {
fetchLogEntry();
}
}, [fetchLogEntry, sourceId, logEntryId]);
return (
<EuiFlyout onClose={closeFlyout} size="m">
<EuiFlyout onClose={onCloseFlyout} size="m">
<EuiFlyoutHeader hasBorder>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
@ -140,12 +68,12 @@ export const LogEntryFlyout = ({
defaultMessage="Details for log entry {logEntryId}"
id="xpack.infra.logFlyout.flyoutTitle"
values={{
logEntryId: flyoutItem ? <code>{flyoutItem.id}</code> : '',
logEntryId: logEntryId ? <code>{logEntryId}</code> : '',
}}
/>
</h3>
</EuiTitle>
{flyoutItem ? (
{logEntry ? (
<>
<EuiSpacer size="s" />
<EuiTextColor color="subdued">
@ -153,7 +81,7 @@ export const LogEntryFlyout = ({
id="xpack.infra.logFlyout.flyoutSubTitle"
defaultMessage="From index {indexName}"
values={{
indexName: <code>{flyoutItem.index}</code>,
indexName: <code>{logEntry.index}</code>,
}}
/>
</EuiTextColor>
@ -161,40 +89,54 @@ export const LogEntryFlyout = ({
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{flyoutItem !== null ? <LogEntryActionsMenu logEntry={flyoutItem} /> : null}
{logEntry ? <LogEntryActionsMenu logEntry={logEntry} /> : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{loading ? (
<InfraFlyoutLoadingPanel>
<InfraLoadingPanel
height="100%"
width="100%"
text={i18n.translate('xpack.infra.logFlyout.loadingMessage', {
defaultMessage: 'Loading Event',
})}
{isRequestRunning ? (
<CenteredEuiFlyoutBody>
<div style={{ width: '75%' }}>
<DataSearchProgress
label={loadingProgressMessage}
maxValue={logEntryRequestTotal}
onCancel={cancelLogEntryRequest}
value={logEntryRequestProgress}
/>
</InfraFlyoutLoadingPanel>
) : flyoutItem ? (
<EuiInMemoryTable<LogEntryField>
columns={columns}
items={flyoutItem.fields}
search={searchOptions}
sorting={initialSortingOptions}
/>
) : (
<InfraFlyoutLoadingPanel>{flyoutError}</InfraFlyoutLoadingPanel>
)}
</EuiFlyoutBody>
</div>
</CenteredEuiFlyoutBody>
) : logEntry ? (
<EuiFlyoutBody
banner={
(logEntryErrors?.length ?? 0) > 0 ? (
<DataSearchErrorCallout
title={loadingErrorCalloutTitle}
errors={logEntryErrors ?? []}
onRetry={fetchLogEntry}
/>
) : undefined
}
>
<LogEntryFieldsTable logEntry={logEntry} onSetFieldFilter={onSetFieldFilter} />
</EuiFlyoutBody>
) : (
<CenteredEuiFlyoutBody>
<div style={{ width: '75%' }}>
<DataSearchErrorCallout
title={loadingErrorCalloutTitle}
errors={logEntryErrors ?? []}
onRetry={fetchLogEntry}
/>
</div>
</CenteredEuiFlyoutBody>
)}
</EuiFlyout>
);
};
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',
});

View file

@ -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) => {

View file

@ -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);
};

View file

@ -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);

View file

@ -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<boolean>(false);
const [flyoutId, setFlyoutId] = useState<string | null>(null);
const [isFlyoutOpen, setIsFlyoutOpen] = useState<boolean>(false);
const [logEntryId, setLogEntryId] = useState<string | null>(null);
const [surroundingLogsId, setSurroundingLogsId] = useState<string | null>(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 (
<UrlStateContainer
urlState={{
flyoutVisibility: flyoutVisible ? FlyoutVisibility.visible : FlyoutVisibility.hidden,
flyoutId,
flyoutVisibility: isFlyoutOpen ? FlyoutVisibility.visible : FlyoutVisibility.hidden,
flyoutId: logEntryId,
surroundingLogsId,
}}
urlStateKey="flyoutOptions"
mapToUrlState={mapToUrlState}
onChange={(newUrlState) => {
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();
}
}}
/>

View file

@ -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<LogEntryFlyoutProps['setFilter']>(
(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 = () => {
</EuiFlexItem>
</EuiFlexGroup>
</ResultsContentPage>
{flyoutVisible ? (
{isLogEntryFlyoutOpen ? (
<LogEntryFlyout
flyoutError={flyoutError}
flyoutItem={flyoutItem}
setFlyoutVisibility={setFlyoutVisibility}
loading={isFlyoutLoading}
setFilter={linkToLogStream}
logEntryId={flyoutLogEntryId}
onCloseFlyout={closeLogEntryFlyout}
onSetFieldFilter={linkToLogStream}
sourceId={sourceId}
/>
) : null}
</>

View file

@ -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<Props> = ({
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<Props> = ({
{
label: VIEW_DETAILS_LABEL,
onClick: () => {
setFlyoutId(id);
setFlyoutVisibility(true);
openLogEntryFlyout(id);
},
},
{
@ -144,13 +143,7 @@ export const LogEntryExampleMessage: React.FunctionComponent<Props> = ({
href: viewAnomalyInMachineLearningLinkProps.href,
},
];
}, [
id,
setFlyoutId,
setFlyoutVisibility,
viewInStreamLinkProps,
viewAnomalyInMachineLearningLinkProps,
]);
}, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]);
return (
<LogEntryRowWrapper

View file

@ -13,7 +13,7 @@ import { ScrollableLogTextStreamView } from '../../../components/logging/log_tex
import { PageContent } from '../../../components/page';
import { LogFilterState } from '../../../containers/logs/log_filter';
import {
LogFlyout as LogFlyoutState,
useLogEntryFlyoutContext,
WithFlyoutOptionsUrlState,
} from '../../../containers/logs/log_flyout';
import { LogHighlightsState } from '../../../containers/logs/log_highlights';
@ -31,15 +31,13 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
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 = () => {
<WithFlyoutOptionsUrlState />
<LogsToolbar />
<PageViewLogInContext />
{flyoutVisible ? (
{isFlyoutOpen ? (
<LogEntryFlyout
setFilter={setFilter}
setFlyoutVisibility={setFlyoutVisibility}
flyoutItem={flyoutItem}
flyoutError={flyoutError}
loading={isLoading}
logEntryId={flyoutLogEntryId}
onCloseFlyout={closeLogEntryFlyout}
onSetFieldFilter={setFilter}
sourceId={sourceId}
/>
) : null}
<PageContent key={`${sourceId}-${sourceConfiguration?.version}`}>
@ -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}

View file

@ -0,0 +1,140 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
<Meta title="infra/dataSearch/Overview" />
# 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.
<Canvas>
<Story id="infra-datasearch-datasearchprogress--undetermined-progress" />
</Canvas>
### 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.
<Canvas>
<Story id="infra-datasearch-datasearchprogress--cancelable-determined-progress" />
</Canvas>
### 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:
<Canvas>
<Story id="infra-datasearch-datasearcherrorcallout--error-callout-with-retry" />
</Canvas>

View file

@ -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';

View file

@ -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 extends IKibanaSearchRequest, RawResponse> {
request: Request;
options: ISearchOptions;
response$: Observable<IKibanaSearchResponse<RawResponse>>;
abortController: AbortController;
}
export interface NormalizedKibanaSearchResponse<ResponseData> {
total?: number;
loaded?: number;
isRunning: boolean;
isPartial: boolean;
data: ResponseData;
errors: SearchStrategyError[];
}
export interface DataSearchResponseDescriptor<Request extends IKibanaSearchRequest, Response> {
request: Request;
options: ISearchOptions;
response: NormalizedKibanaSearchResponse<Response>;
abortController: AbortController;
}

View file

@ -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<PluginKibanaContextValue>
>({
data: dataMock,
});
const getRequest = jest.fn((_firstArgument: string, _secondArgument: string) => null);
const { result } = renderHook(
() =>
useDataSearch({
getRequest,
}),
{
wrapper: ({ children }) => <KibanaContextProvider>{children}</KibanaContextProvider>,
}
);
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<IKibanaSearchResponse>({
rawResponse: {
firstKey: 'firstValue',
},
});
dataMock.search.search.mockReturnValue(searchResponseMock$);
const { Provider: KibanaContextProvider } = createKibanaReactContext<
Partial<PluginKibanaContextValue>
>({
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 }) => <KibanaContextProvider>{children}</KibanaContextProvider>,
}
);
// 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<IKibanaSearchResponse>();
dataMock.search.search.mockReturnValue(searchResponseMock$);
const { Provider: KibanaContextProvider } = createKibanaReactContext<
Partial<PluginKibanaContextValue>
>({
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 }) => <KibanaContextProvider>{children}</KibanaContextProvider>,
}
);
// 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<ISearchGeneric> };
};
return dataMock;
};

View file

@ -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 extends any[], Request extends IKibanaSearchRequest> = (
...args: Args
) =>
| {
request: Request;
options: ISearchOptions;
}
| null
| undefined;
export const useDataSearch = <
RequestFactoryArgs extends any[],
Request extends IKibanaSearchRequest,
RawResponse
>({
getRequest,
}: {
getRequest: DataSearchRequestFactory<RequestFactoryArgs, Request>;
}) => {
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, IKibanaSearchResponse<RawResponse>>(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,
};
};

View file

@ -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<IKibanaSearchResponse<string>>(),
};
const secondRequest = {
abortController: new AbortController(),
options: {},
request: { params: 'secondRequestParam' },
response$: new Subject<IKibanaSearchResponse<string>>(),
};
const requests$ = new Subject<
DataSearchRequestDescriptor<IKibanaSearchRequest<string>, 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<IKibanaSearchResponse<string>>(() => {
return onUnsubscribe;
}),
};
const requests$ = of<DataSearchRequestDescriptor<IKibanaSearchRequest<string>, string>>(
firstRequest
);
const { unmount } = renderHook(() =>
useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({
data: `projection of ${response}`,
}))
);
expect(onUnsubscribe).not.toHaveBeenCalled();
unmount();
expect(onUnsubscribe).toHaveBeenCalled();
});
});

View file

@ -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<DataSearchRequestDescriptor<Request, RawResponse>>,
initialResponse: InitialResponse,
projectResponse: (rawResponse: RawResponse) => { data: Response; errors?: SearchStrategyError[] }
) => {
const latestInitialResponse = useLatest(initialResponse);
const latestProjectResponse = useLatest(projectResponse);
const latestResponse$: Observable<
DataSearchResponseDescriptor<Request, Response | InitialResponse>
> = 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,
};
};

View file

@ -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: Value) => {
const valueRef = useRef(value);
valueRef.current = value;
return valueRef;
};
export const useObservable = <
OutputValue,
OutputObservable extends Observable<OutputValue>,
InputValues extends Readonly<any[]>
>(
createObservableOnce: (inputValues: Observable<InputValues>) => OutputObservable,
inputValues: InputValues
) => {
const [inputValues$] = useState(() => new BehaviorSubject<InputValues>(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, InitialState>(
state$: Observable<State>,
initialState: InitialState | (() => InitialState)
) => {
const [latestValue, setLatestValue] = useState<State | InitialState>(initialState);
const [latestError, setLatestError] = useState<unknown>();
useSubscription(state$, {
next: setLatestValue,
error: setLatestError,
});
return { latestValue, latestError };
};
export const useSubscription = <InputValue>(
input$: Observable<InputValue>,
{ next, error, complete, unsubscribe }: PartialObserver<InputValue> & { unsubscribe?: () => void }
) => {
const latestSubscription = useRef<Subscription | undefined>();
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) => <T>(source$: Observable<T>) => {
return new Observable<T>((subscriber) => {
const subscription = source$.subscribe({
next: (value) => subscriber.next(value),
error: (error) => subscriber.error(error),
complete: () => subscriber.complete(),
});
return () => {
onUnsubscribe();
subscription.unsubscribe();
};
});
};

View file

@ -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 => ({

View file

@ -17,7 +17,7 @@ export const createGetLogEntryQuery = (
logEntryId: string,
timestampField: string,
tiebreakerField: string
): RequestParams.Search<Record<string, any>> => ({
): RequestParams.AsyncSearchSubmit<Record<string, any>> => ({
index: logEntryIndex,
terminate_after: 1,
track_scores: false,