[Logs UI] Add helper hooks with search strategy request cancellation (#83906)
This commit is contained in:
parent
e5c7134925
commit
efe62acd80
|
@ -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],
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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' },
|
||||
};
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
|
@ -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' },
|
||||
};
|
|
@ -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',
|
||||
});
|
|
@ -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',
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
};
|
62
x-pack/plugins/infra/public/containers/logs/log_entry.ts
Normal file
62
x-pack/plugins/infra/public/containers/logs/log_entry.ts
Normal 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);
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
9
x-pack/plugins/infra/public/utils/data_search/index.ts
Normal file
9
x-pack/plugins/infra/public/utils/data_search/index.ts
Normal 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';
|
36
x-pack/plugins/infra/public/utils/data_search/types.ts
Normal file
36
x-pack/plugins/infra/public/utils/data_search/types.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
94
x-pack/plugins/infra/public/utils/use_observable.ts
Normal file
94
x-pack/plugins/infra/public/utils/use_observable.ts
Normal 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();
|
||||
};
|
||||
});
|
||||
};
|
|
@ -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 => ({
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue