From 3ddb9207d8f895c07e6cad97791965a10f22666e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 11 Mar 2020 20:07:55 +0100 Subject: [PATCH] [Logs UI] Add expandable rows with category examples (#54586) Make the category rows expandable to show actual log messages samples from the categories during the selected time frame. --- .../http_api/log_analysis/results/index.ts | 1 + .../results/log_entry_categories.ts | 9 +- .../results/log_entry_category_examples.ts | 75 +++++++ .../common/log_analysis/job_parameters.ts | 7 + .../log_analysis/log_analysis_results.ts | 7 + .../public/components/basic_table/index.ts | 7 + .../basic_table/row_expansion_button.tsx | 44 ++++ .../public/components/formatted_time.tsx | 4 +- .../analyze_in_ml_button.tsx | 12 +- .../logging/log_text_stream/index.ts | 5 + .../log_text_stream/log_entry_column.tsx | 37 +++- .../log_entry_field_column.test.tsx | 6 +- .../log_entry_field_column.tsx | 44 ++-- .../log_entry_message_column.tsx | 41 ++-- .../logging/log_text_stream/log_entry_row.tsx | 11 +- .../log_entry_timestamp_column.tsx | 7 +- .../scrollable_log_text_stream_view.tsx | 29 +-- .../logging/log_text_stream/text_styles.tsx | 18 ++ .../logs/log_analysis/api/ml_api_types.ts | 7 - .../api/ml_get_jobs_summary_api.ts | 8 +- .../logs/log_analysis/api/ml_get_module.ts | 7 +- .../log_analysis/api/ml_setup_module_api.ts | 12 +- .../page_results_content.tsx | 1 + .../analyze_dataset_in_ml_action.tsx | 48 +++++ .../anomaly_severity_indicator_list.tsx | 26 +++ .../top_categories/category_details_row.tsx | 68 ++++++ .../category_example_message.tsx | 124 +++++++++++ ...egory_example_messages_empty_indicator.tsx | 29 +++ ...ory_example_messages_failure_indicator.tsx | 31 +++ ...ory_example_messages_loading_indicator.tsx | 18 ++ .../top_categories/datasets_action_list.tsx | 35 ++++ .../sections/top_categories/datasets_list.tsx | 24 ++- .../top_categories/top_categories_section.tsx | 9 +- .../top_categories/top_categories_table.tsx | 92 +++++++- .../get_log_entry_category_examples.ts | 47 +++++ .../use_log_entry_category_examples.tsx | 65 ++++++ .../sections/anomalies/table.tsx | 61 +++--- x-pack/plugins/infra/server/infra_server.ts | 2 + .../lib/adapters/framework/adapter_types.ts | 2 + .../framework/kibana_framework_adapter.ts | 5 + .../infra/server/lib/log_analysis/errors.ts | 23 ++ .../log_entry_categories_analysis.ts | 197 +++++++++++++++++- .../server/lib/log_analysis/queries/common.ts | 8 + .../queries/log_entry_categories.ts | 13 +- .../queries/log_entry_category_examples.ts | 78 +++++++ .../lib/log_analysis/queries/ml_jobs.ts | 24 +++ .../queries/top_log_entry_categories.ts | 22 ++ .../routes/log_analysis/results/index.ts | 1 + .../results/log_entry_category_examples.ts | 86 ++++++++ .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 51 files changed, 1365 insertions(+), 176 deletions(-) create mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts create mode 100644 x-pack/plugins/infra/public/components/basic_table/index.ts create mode 100644 x-pack/plugins/infra/public/components/basic_table/row_expansion_button.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_examples.ts create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_category_examples.tsx create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/queries/ml_jobs.ts create mode 100644 x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index d9ca9a96ffe51..15615046bdd6a 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -6,4 +6,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; +export * from './log_entry_category_examples'; export * from './log_entry_rate'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts index 66823c25237ac..f56462012d2e9 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -72,9 +72,16 @@ export const logEntryCategoryHistogramRT = rt.type({ export type LogEntryCategoryHistogram = rt.TypeOf; +export const logEntryCategoryDatasetRT = rt.type({ + name: rt.string, + maximumAnomalyScore: rt.number, +}); + +export type LogEntryCategoryDataset = rt.TypeOf; + export const logEntryCategoryRT = rt.type({ categoryId: rt.number, - datasets: rt.array(rt.string), + datasets: rt.array(logEntryCategoryDatasetRT), histograms: rt.array(logEntryCategoryHistogramRT), logEntryCount: rt.number, maximumAnomalyScore: rt.number, diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts new file mode 100644 index 0000000000000..d014da8bca262 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts @@ -0,0 +1,75 @@ +/* + * 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 * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH = + '/api/infra/log_analysis/results/log_entry_category_examples'; + +/** + * request + */ + +export const getLogEntryCategoryExamplesRequestPayloadRT = rt.type({ + data: rt.type({ + // the category to fetch the examples for + categoryId: rt.number, + // the number of examples to fetch + exampleCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the category examples from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryCategoryExamplesRequestPayload = rt.TypeOf< + typeof getLogEntryCategoryExamplesRequestPayloadRT +>; + +/** + * response + */ + +const logEntryCategoryExampleRT = rt.type({ + dataset: rt.string, + message: rt.string, + timestamp: rt.number, +}); + +export type LogEntryCategoryExample = rt.TypeOf; + +export const getLogEntryCategoryExamplesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + examples: rt.array(logEntryCategoryExampleRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoryExamplesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoryExamplesSuccessReponsePayloadRT +>; + +export const getLogEntryCategoryExamplesResponsePayloadRT = rt.union([ + getLogEntryCategoryExamplesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoryExamplesReponsePayload = rt.TypeOf< + typeof getLogEntryCategoryExamplesResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/log_analysis/job_parameters.ts b/x-pack/plugins/infra/common/log_analysis/job_parameters.ts index 8c08e24d8665d..94643e21f1ea6 100644 --- a/x-pack/plugins/infra/common/log_analysis/job_parameters.ts +++ b/x-pack/plugins/infra/common/log_analysis/job_parameters.ts @@ -28,3 +28,10 @@ export const jobSourceConfigurationRT = rt.type({ }); export type JobSourceConfiguration = rt.TypeOf; + +export const jobCustomSettingsRT = rt.partial({ + job_revision: rt.number, + logs_source_config: rt.partial(jobSourceConfigurationRT.props), +}); + +export type JobCustomSettings = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts index 1dcd4a10fc4e3..19c92cb381104 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -44,3 +44,10 @@ export const formatAnomalyScore = (score: number) => { export const getFriendlyNameForPartitionId = (partitionId: string) => { return partitionId !== '' ? partitionId : 'unknown'; }; + +export const compareDatasetsByMaximumAnomalyScore = < + Dataset extends { maximumAnomalyScore: number } +>( + firstDataset: Dataset, + secondDataset: Dataset +) => firstDataset.maximumAnomalyScore - secondDataset.maximumAnomalyScore; diff --git a/x-pack/plugins/infra/public/components/basic_table/index.ts b/x-pack/plugins/infra/public/components/basic_table/index.ts new file mode 100644 index 0000000000000..a1d21792d60a4 --- /dev/null +++ b/x-pack/plugins/infra/public/components/basic_table/index.ts @@ -0,0 +1,7 @@ +/* + * 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 './row_expansion_button'; diff --git a/x-pack/plugins/infra/public/components/basic_table/row_expansion_button.tsx b/x-pack/plugins/infra/public/components/basic_table/row_expansion_button.tsx new file mode 100644 index 0000000000000..debde78470953 --- /dev/null +++ b/x-pack/plugins/infra/public/components/basic_table/row_expansion_button.tsx @@ -0,0 +1,44 @@ +/* + * 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 } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback } from 'react'; + +export const RowExpansionButton = ({ + isExpanded, + item, + onCollapse, + onExpand, +}: { + isExpanded: boolean; + item: Item; + onCollapse: (item: Item) => void; + onExpand: (item: Item) => void; +}) => { + const handleClick = useCallback(() => (isExpanded ? onCollapse(item) : onExpand(item)), [ + isExpanded, + item, + onCollapse, + onExpand, + ]); + + return ( + + ); +}; + +const collapseAriaLabel = i18n.translate('xpack.infra.table.collapseRowLabel', { + defaultMessage: 'Collapse', +}); + +const expandAriaLabel = i18n.translate('xpack.infra.table.expandRowLabel', { + defaultMessage: 'Expand', +}); diff --git a/x-pack/plugins/infra/public/components/formatted_time.tsx b/x-pack/plugins/infra/public/components/formatted_time.tsx index 46b505d4fab52..67f4ab5adc597 100644 --- a/x-pack/plugins/infra/public/components/formatted_time.tsx +++ b/x-pack/plugins/infra/public/components/formatted_time.tsx @@ -17,8 +17,10 @@ const getFormattedTime = ( return userFormat ? moment(time).format(userFormat) : moment(time).format(fallbackFormat); }; +export type TimeFormat = 'dateTime' | 'time'; + interface UseFormattedTimeOptions { - format?: 'dateTime' | 'time'; + format?: TimeFormat; fallbackFormat?: string; } diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx index 7d523faafdb3c..5383cb3b09d39 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx @@ -18,7 +18,9 @@ export const AnalyzeInMlButton: React.FunctionComponent<{ }> = ({ jobId, partition, timeRange }) => { const linkProps = useLinkProps( typeof partition === 'string' - ? getPartitionSpecificSingleMetricViewerLinkDescriptor(jobId, partition, timeRange) + ? getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { + 'event.dataset': partition, + }) : getOverallAnomalyExplorerLinkDescriptor(jobId, timeRange) ); const buttonLabel = ( @@ -61,10 +63,10 @@ const getOverallAnomalyExplorerLinkDescriptor = ( }; }; -const getPartitionSpecificSingleMetricViewerLinkDescriptor = ( +export const getEntitySpecificSingleMetricViewerLink = ( jobId: string, - partition: string, - timeRange: TimeRange + timeRange: TimeRange, + entities: Record ): LinkDescriptor => { const { from, to } = convertTimeRangeToParams(timeRange); @@ -81,7 +83,7 @@ const getPartitionSpecificSingleMetricViewerLinkDescriptor = ( const _a = encode({ mlTimeSeriesExplorer: { - entities: { 'event.dataset': partition }, + entities, }, }); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts index 77781b58a3ccd..dbf162171cac3 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/index.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +export { LogEntryColumn, LogEntryColumnWidths, useColumnWidths } from './log_entry_column'; +export { LogEntryFieldColumn } from './log_entry_field_column'; +export { LogEntryMessageColumn } from './log_entry_message_column'; +export { LogEntryRowWrapper } from './log_entry_row'; +export { LogEntryTimestampColumn } from './log_entry_timestamp_column'; export { ScrollableLogTextStreamView } from './scrollable_log_text_stream_view'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_column.tsx index bcb5400c72209..b0518b96e758c 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_column.tsx @@ -4,12 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { useMemo } from 'react'; + import { euiStyled } from '../../../../../observability/public'; +import { TextScale } from '../../../../common/log_text_scale'; import { - LogColumnConfiguration, isMessageLogColumnConfiguration, isTimestampLogColumnConfiguration, + LogColumnConfiguration, } from '../../../utils/source_configuration'; +import { useFormattedTime, TimeFormat } from '../../formatted_time'; +import { useMeasuredCharacterDimensions } from './text_styles'; const DATE_COLUMN_SLACK_FACTOR = 1.1; const FIELD_COLUMN_MIN_WIDTH_CHARACTERS = 10; @@ -100,3 +105,33 @@ export const getColumnWidths = ( }, } ); + +/** + * This hook calculates the column widths based on the given configuration. It + * depends on the `CharacterDimensionsProbe` it returns being rendered so it can + * measure the monospace character size. + */ +export const useColumnWidths = ({ + columnConfigurations, + scale, + timeFormat = 'time', +}: { + columnConfigurations: LogColumnConfiguration[]; + scale: TextScale; + timeFormat?: TimeFormat; +}) => { + const { CharacterDimensionsProbe, dimensions } = useMeasuredCharacterDimensions(scale); + const referenceTime = useMemo(() => Date.now(), []); + const formattedCurrentDate = useFormattedTime(referenceTime, { format: timeFormat }); + const columnWidths = useMemo( + () => getColumnWidths(columnConfigurations, dimensions.width, formattedCurrentDate.length), + [columnConfigurations, dimensions.width, formattedCurrentDate] + ); + return useMemo( + () => ({ + columnWidths, + CharacterDimensionsProbe, + }), + [columnWidths, CharacterDimensionsProbe] + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 8589f82ba15dd..5d295ca7e4817 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -26,7 +26,7 @@ describe('LogEntryFieldColumn', () => { isActiveHighlight={false} isHighlighted={false} isHovered={false} - isWrapped={false} + wrapMode="pre-wrapped" />, { wrappingComponent: EuiThemeProvider } as any // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/36075 ); @@ -58,7 +58,7 @@ describe('LogEntryFieldColumn', () => { isActiveHighlight={false} isHighlighted={false} isHovered={false} - isWrapped={false} + wrapMode="pre-wrapped" />, { wrappingComponent: EuiThemeProvider } as any // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/36075 ); @@ -80,7 +80,7 @@ describe('LogEntryFieldColumn', () => { isActiveHighlight={false} isHighlighted={false} isHovered={false} - isWrapped={false} + wrapMode="pre-wrapped" />, { wrappingComponent: EuiThemeProvider } as any // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/36075 ); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index 705a7f4c12de4..c6584f2fdbb6d 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -5,10 +5,9 @@ */ import stringify from 'json-stable-stringify'; -import { darken, transparentize } from 'polished'; import React, { useMemo } from 'react'; -import { euiStyled, css } from '../../../../../observability/public'; +import { euiStyled } from '../../../../../observability/public'; import { isFieldColumn, isHighlightFieldColumn, @@ -17,6 +16,13 @@ import { } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; +import { + hoveredContentStyle, + longWrappedContentStyle, + preWrappedContentStyle, + unwrappedContentStyle, + WrapMode, +} from './text_styles'; interface LogEntryFieldColumnProps { columnValue: LogEntryColumn; @@ -24,7 +30,7 @@ interface LogEntryFieldColumnProps { isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; - isWrapped: boolean; + wrapMode: WrapMode; } export const LogEntryFieldColumn: React.FunctionComponent = ({ @@ -33,7 +39,7 @@ export const LogEntryFieldColumn: React.FunctionComponent { const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [ columnValue, @@ -59,30 +65,12 @@ export const LogEntryFieldColumn: React.FunctionComponent + {formattedValue} ); }; -const hoveredContentStyle = css` - background-color: ${props => - props.theme.darkMode - ? transparentize(0.9, darken(0.05, props.theme.eui.euiColorHighlight)) - : darken(0.05, props.theme.eui.euiColorHighlight)}; -`; - -const wrappedContentStyle = css` - overflow: visible; - white-space: pre-wrap; - word-break: break-all; -`; - -const unwrappedContentStyle = css` - overflow: hidden; - white-space: pre; -`; - const CommaSeparatedLi = euiStyled.li` display: inline; &:not(:last-child) { @@ -96,13 +84,17 @@ const CommaSeparatedLi = euiStyled.li` interface LogEntryColumnContentProps { isHighlighted: boolean; isHovered: boolean; - isWrapped?: boolean; + wrapMode: WrapMode; } const FieldColumnContent = euiStyled(LogEntryColumnContent)` - background-color: ${props => props.theme.eui.euiColorEmptyShade}; text-overflow: ellipsis; ${props => (props.isHovered || props.isHighlighted ? hoveredContentStyle : '')}; - ${props => (props.isWrapped ? wrappedContentStyle : unwrappedContentStyle)}; + ${props => + props.wrapMode === 'long' + ? longWrappedContentStyle + : props.wrapMode === 'pre-wrapped' + ? preWrappedContentStyle + : unwrappedContentStyle}; `; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index 10bc2a7b4597a..122f0fe472c6e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -6,7 +6,7 @@ import React, { memo, useMemo } from 'react'; -import { euiStyled, css } from '../../../../../observability/public'; +import { euiStyled } from '../../../../../observability/public'; import { isConstantSegment, isFieldSegment, @@ -18,7 +18,13 @@ import { } from '../../../utils/log_entry'; import { ActiveHighlightMarker, highlightFieldValue, HighlightMarker } from './highlighting'; import { LogEntryColumnContent } from './log_entry_column'; -import { hoveredContentStyle } from './text_styles'; +import { + hoveredContentStyle, + longWrappedContentStyle, + preWrappedContentStyle, + unwrappedContentStyle, + WrapMode, +} from './text_styles'; interface LogEntryMessageColumnProps { columnValue: LogEntryColumn; @@ -26,11 +32,11 @@ interface LogEntryMessageColumnProps { isActiveHighlight: boolean; isHighlighted: boolean; isHovered: boolean; - isWrapped: boolean; + wrapMode: WrapMode; } export const LogEntryMessageColumn = memo( - ({ columnValue, highlights, isActiveHighlight, isHighlighted, isHovered, isWrapped }) => { + ({ columnValue, highlights, isActiveHighlight, isHighlighted, isHovered, wrapMode }) => { const message = useMemo( () => isMessageColumn(columnValue) @@ -40,40 +46,29 @@ export const LogEntryMessageColumn = memo( ); return ( - + {message} ); } ); -const wrappedContentStyle = css` - overflow: visible; - white-space: pre-wrap; - word-break: break-all; -`; - -const unwrappedContentStyle = css` - overflow: hidden; - white-space: pre; -`; - interface MessageColumnContentProps { isHovered: boolean; isHighlighted: boolean; - isWrapped?: boolean; + wrapMode: WrapMode; } const MessageColumnContent = euiStyled(LogEntryColumnContent)` - background-color: ${props => props.theme.eui.euiColorEmptyShade}; text-overflow: ellipsis; ${props => (props.isHovered || props.isHighlighted ? hoveredContentStyle : '')}; - ${props => (props.isWrapped ? wrappedContentStyle : unwrappedContentStyle)}; + ${props => + props.wrapMode === 'long' + ? longWrappedContentStyle + : props.wrapMode === 'pre-wrapped' + ? preWrappedContentStyle + : unwrappedContentStyle}; `; const formatMessageSegments = ( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 566cc7ec09336..e5e3740f420e8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// import { darken, transparentize } from 'polished'; import React, { memo, useState, useCallback, useMemo } from 'react'; import { euiStyled } from '../../../../../observability/public'; @@ -36,7 +35,7 @@ interface LogEntryRowProps { isActiveHighlight: boolean; isHighlighted: boolean; logEntry: LogEntry; - openFlyoutWithItem: (id: string) => void; + openFlyoutWithItem?: (id: string) => void; scale: TextScale; wrap: boolean; } @@ -64,7 +63,7 @@ export const LogEntryRow = memo( setIsHovered(false); }, []); - const openFlyout = useCallback(() => openFlyoutWithItem(logEntry.gid), [ + const openFlyout = useCallback(() => openFlyoutWithItem?.(logEntry.gid), [ openFlyoutWithItem, logEntry.gid, ]); @@ -149,7 +148,7 @@ export const LogEntryRow = memo( isHighlighted={isHighlighted} isActiveHighlight={isActiveHighlight} isHovered={isHovered} - isWrapped={wrap} + wrapMode={wrap ? 'long' : 'pre-wrapped'} /> ) : null} @@ -171,7 +170,7 @@ export const LogEntryRow = memo( isActiveHighlight={isActiveHighlight} isHighlighted={isHighlighted} isHovered={isHovered} - isWrapped={wrap} + wrapMode={wrap ? 'long' : 'pre-wrapped'} /> ) : null} @@ -197,7 +196,7 @@ interface LogEntryRowWrapperProps { scale: TextScale; } -const LogEntryRowWrapper = euiStyled.div.attrs(() => ({ +export const LogEntryRowWrapper = euiStyled.div.attrs(() => ({ role: 'row', }))` align-items: stretch; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_timestamp_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_timestamp_column.tsx index e3c1e80a43a1a..f3ea9c81108c6 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_timestamp_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_timestamp_column.tsx @@ -8,18 +8,19 @@ import { darken, transparentize } from 'polished'; import React, { memo } from 'react'; import { euiStyled, css } from '../../../../../observability/public'; -import { useFormattedTime } from '../../formatted_time'; +import { TimeFormat, useFormattedTime } from '../../formatted_time'; import { LogEntryColumnContent } from './log_entry_column'; interface LogEntryTimestampColumnProps { + format?: TimeFormat; isHighlighted: boolean; isHovered: boolean; time: number; } export const LogEntryTimestampColumn = memo( - ({ isHighlighted, isHovered, time }) => { - const formattedTime = useFormattedTime(time, { format: 'time' }); + ({ format = 'time', isHighlighted, isHovered, time }) => { + const formattedTime = useFormattedTime(time, { format }); return ( diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 0bf121cf6c1eb..6544a32ba414c 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { Fragment, useMemo } from 'react'; +import React, { Fragment } from 'react'; import moment from 'moment'; import { euiStyled } from '../../../../../observability/public'; @@ -16,7 +16,6 @@ import { callWithoutRepeats } from '../../../utils/handlers'; import { LogColumnConfiguration } from '../../../utils/source_configuration'; import { AutoSizer } from '../../auto_sizer'; import { NoData } from '../../empty_states'; -import { useFormattedTime } from '../../formatted_time'; import { InfraLoadingPanel } from '../../loading'; import { getStreamItemBeforeTimeKey, getStreamItemId, parseStreamItemId, StreamItem } from './item'; import { LogColumnHeaders } from './column_headers'; @@ -25,8 +24,7 @@ import { LogTextStreamJumpToTail } from './jump_to_tail'; import { LogEntryRow } from './log_entry_row'; import { MeasurableItemView } from './measurable_item_view'; import { VerticalScrollPanel } from './vertical_scroll_panel'; -import { getColumnWidths, LogEntryColumnWidths } from './log_entry_column'; -import { useMeasuredCharacterDimensions } from './text_styles'; +import { useColumnWidths, LogEntryColumnWidths } from './log_entry_column'; import { LogDateRow } from './log_date_row'; interface ScrollableLogTextStreamViewProps { @@ -330,12 +328,8 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } /** - * This function-as-child component calculates the column widths based on the - * given configuration. It depends on the `CharacterDimensionsProbe` it returns - * being rendered so it can measure the monospace character size. - * - * If the above component wasn't a class component, this would have been - * written as a hook. + * If the above component wasn't a class component, this wouldn't be necessary + * since the `useColumnWidths` hook could have been used directly. */ const WithColumnWidths: React.FunctionComponent<{ children: (params: { @@ -345,20 +339,7 @@ const WithColumnWidths: React.FunctionComponent<{ columnConfigurations: LogColumnConfiguration[]; scale: TextScale; }> = ({ children, columnConfigurations, scale }) => { - const { CharacterDimensionsProbe, dimensions } = useMeasuredCharacterDimensions(scale); - const referenceTime = useMemo(() => Date.now(), []); - const formattedCurrentDate = useFormattedTime(referenceTime, { format: 'time' }); - const columnWidths = useMemo( - () => getColumnWidths(columnConfigurations, dimensions.width, formattedCurrentDate.length), - [columnConfigurations, dimensions.width, formattedCurrentDate] - ); - const childParams = useMemo( - () => ({ - columnWidths, - CharacterDimensionsProbe, - }), - [columnWidths, CharacterDimensionsProbe] - ); + const childParams = useColumnWidths({ columnConfigurations, scale }); return children(childParams); }; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/text_styles.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/text_styles.tsx index 6857f94105dad..434258343eefb 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/text_styles.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/text_styles.tsx @@ -10,6 +10,8 @@ import React, { useMemo, useState, useCallback } from 'react'; import { euiStyled, css } from '../../../../../observability/public'; import { TextScale } from '../../../../common/log_text_scale'; +export type WrapMode = 'none' | 'pre-wrapped' | 'long'; + export const monospaceTextStyle = (scale: TextScale) => css` font-family: ${props => props.theme.eui.euiCodeFontFamily}; font-size: ${props => { @@ -34,6 +36,22 @@ export const hoveredContentStyle = css` : darken(0.05, props.theme.eui.euiColorHighlight)}; `; +export const longWrappedContentStyle = css` + overflow: visible; + white-space: pre-wrap; + word-break: break-all; +`; + +export const preWrappedContentStyle = css` + overflow: hidden; + white-space: pre; +`; + +export const unwrappedContentStyle = css` + overflow: hidden; + white-space: nowrap; +`; + interface CharacterDimensions { height: number; width: number; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_api_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_api_types.ts index 9d4d419ceebe3..ee70edc31d49b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_api_types.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_api_types.ts @@ -6,13 +6,6 @@ import * as rt from 'io-ts'; -import { jobSourceConfigurationRT } from '../../../../../common/log_analysis'; - -export const jobCustomSettingsRT = rt.partial({ - job_revision: rt.number, - logs_source_config: rt.partial(jobSourceConfigurationRT.props), -}); - export const getMlCapabilitiesResponsePayloadRT = rt.type({ capabilities: rt.type({ canGetJobs: rt.boolean, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts index ef463561a863f..49112dd8ec8d8 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; import * as rt from 'io-ts'; import { npStart } from '../../../../legacy_singletons'; -import { jobCustomSettingsRT } from './ml_api_types'; -import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; -import { getJobId } from '../../../../../common/log_analysis'; + +import { getJobId, jobCustomSettingsRT } from '../../../../../common/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; export const callJobsSummaryAPI = async ( spaceId: string, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_module.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_module.ts index 7c34eeca76a98..b6b40d6dc651f 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_module.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_get_module.ts @@ -5,12 +5,13 @@ */ import { fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; import * as rt from 'io-ts'; import { npStart } from '../../../../legacy_singletons'; -import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; -import { jobCustomSettingsRT } from './ml_api_types'; + +import { jobCustomSettingsRT } from '../../../../../common/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; export const callGetMlModuleAPI = async (moduleId: string) => { const response = await npStart.http.fetch(`/api/ml/modules/get_module/${moduleId}`, { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts index d90bbab267871..b1265b389917e 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts @@ -5,12 +5,13 @@ */ import { fold } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; import * as rt from 'io-ts'; import { npStart } from '../../../../legacy_singletons'; -import { throwErrors, createPlainError } from '../../../../../common/runtime_types'; -import { getJobIdPrefix } from '../../../../../common/log_analysis'; + +import { getJobIdPrefix, jobCustomSettingsRT } from '../../../../../common/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; export const callSetupMlModuleAPI = async ( moduleId: string, @@ -48,7 +49,10 @@ const setupMlModuleTimeParamsRT = rt.partial({ end: rt.number, }); -const setupMlModuleJobOverridesRT = rt.object; +const setupMlModuleJobOverridesRT = rt.type({ + job_id: rt.string, + custom_settings: jobCustomSettingsRT, +}); export type SetupMlModuleJobOverrides = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 5826435878378..b783aa9c79007 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -197,6 +197,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { onChangeDatasetSelection={setCategoryQueryDatasets} onRequestRecreateMlJob={viewSetupForReconfiguration} selectedDatasets={categoryQueryDatasets} + sourceId={sourceId} timeRange={categoryQueryTimeRange.timeRange} topCategories={topLogEntryCategories} /> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx new file mode 100644 index 0000000000000..3e1398c804686 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx @@ -0,0 +1,48 @@ +/* + * 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, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results'; +import { useLinkProps } from '../../../../../hooks/use_link_props'; + +export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ + categorizationJobId: string; + categoryId: number; + dataset: string; + timeRange: TimeRange; +}> = ({ categorizationJobId, categoryId, dataset, timeRange }) => { + const linkProps = useLinkProps( + getEntitySpecificSingleMetricViewerLink(categorizationJobId, timeRange, { + 'event.dataset': dataset, + mlcategory: `${categoryId}`, + }) + ); + + return ( + + + + ); +}; + +const analyseCategoryDatasetInMlButtonLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.analyzeCategoryInMlButtonLabel', + { defaultMessage: 'Analyze in ML' } +); + +const analyseCategoryDatasetInMlTooltipDescription = i18n.translate( + 'xpack.infra.logs.logEntryCategories.analyzeCategoryInMlTooltipDescription', + { defaultMessage: 'Analyze this category in the ML app.' } +); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx new file mode 100644 index 0000000000000..6035c032bdece --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx @@ -0,0 +1,26 @@ +/* + * 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 React from 'react'; + +import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; + +export const AnomalySeverityIndicatorList: React.FunctionComponent<{ + datasets: LogEntryCategoryDataset[]; +}> = ({ datasets }) => ( +
    + {datasets.map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset.name); + return ( +
  • + +
  • + ); + })} +
+); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx new file mode 100644 index 0000000000000..c0728c0a55483 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx @@ -0,0 +1,68 @@ +/* + * 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 React, { useEffect } from 'react'; + +import { euiStyled } from '../../../../../../../observability/public'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { useLogEntryCategoryExamples } from '../../use_log_entry_category_examples'; +import { CategoryExampleMessage } from './category_example_message'; +import { CategoryExampleMessagesEmptyIndicator } from './category_example_messages_empty_indicator'; +import { CategoryExampleMessagesFailureIndicator } from './category_example_messages_failure_indicator'; +import { CategoryExampleMessagesLoadingIndicator } from './category_example_messages_loading_indicator'; + +const exampleCount = 5; + +export const CategoryDetailsRow: React.FunctionComponent<{ + categoryId: number; + timeRange: TimeRange; + sourceId: string; +}> = ({ categoryId, timeRange, sourceId }) => { + const { + getLogEntryCategoryExamples, + hasFailedLoadingLogEntryCategoryExamples, + isLoadingLogEntryCategoryExamples, + logEntryCategoryExamples, + } = useLogEntryCategoryExamples({ + categoryId, + endTime: timeRange.endTime, + exampleCount, + sourceId, + startTime: timeRange.startTime, + }); + + useEffect(() => { + getLogEntryCategoryExamples(); + }, [getLogEntryCategoryExamples]); + + return ( + + {isLoadingLogEntryCategoryExamples ? ( + + ) : hasFailedLoadingLogEntryCategoryExamples ? ( + + ) : logEntryCategoryExamples.length === 0 ? ( + + ) : ( + logEntryCategoryExamples.map((categoryExample, categoryExampleIndex) => ( + + )) + )} + + ); +}; + +const CategoryExampleMessages = euiStyled.div` + align-items: stretch; + flex-direction: column; + flex: 1 0 0%; + overflow: hidden; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx new file mode 100644 index 0000000000000..54609bcf8e2c2 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -0,0 +1,124 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { + LogEntryColumn, + LogEntryFieldColumn, + LogEntryMessageColumn, + LogEntryRowWrapper, + LogEntryTimestampColumn, +} from '../../../../../components/logging/log_text_stream'; +import { LogColumnConfiguration } from '../../../../../utils/source_configuration'; + +export const exampleMessageScale = 'medium' as const; +export const exampleTimestampFormat = 'dateTime' as const; + +export const CategoryExampleMessage: React.FunctionComponent<{ + dataset: string; + message: string; + timestamp: number; +}> = ({ dataset, message, timestamp }) => { + // the dataset must be encoded for the field column and the empty value must + // be turned into a user-friendly value + const encodedDatasetFieldValue = useMemo( + () => JSON.stringify(getFriendlyNameForPartitionId(dataset)), + [dataset] + ); + + return ( + + + + + + + + + + + + ); +}; + +const noHighlights: never[] = []; +const timestampColumnId = 'category-example-timestamp-column' as const; +const messageColumnId = 'category-examples-message-column' as const; +const datasetColumnId = 'category-examples-dataset-column' as const; + +const columnWidths = { + [timestampColumnId]: { + growWeight: 0, + shrinkWeight: 0, + // w_count + w_trend - w_padding = 120 px + 220 px - 8 px + baseWidth: '332px', + }, + [messageColumnId]: { + growWeight: 1, + shrinkWeight: 0, + baseWidth: '0%', + }, + [datasetColumnId]: { + growWeight: 0, + shrinkWeight: 0, + // w_dataset + w_max_anomaly + w_expand - w_padding = 200 px + 160 px + 40 px + 40 px - 8 px + baseWidth: '432px', + }, +}; + +export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [ + { + __typename: 'InfraSourceTimestampLogColumn', + timestampColumn: { + id: timestampColumnId, + }, + }, + { + __typename: 'InfraSourceMessageLogColumn', + messageColumn: { + id: messageColumnId, + }, + }, + { + __typename: 'InfraSourceFieldLogColumn', + fieldColumn: { + field: 'event.dataset', + id: datasetColumnId, + }, + }, +]; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx new file mode 100644 index 0000000000000..ac572a5f6cf21 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_empty_indicator.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const CategoryExampleMessagesEmptyIndicator: React.FunctionComponent<{ + onReload: () => void; +}> = ({ onReload }) => ( + + + + + + + + + + +); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx new file mode 100644 index 0000000000000..7865dcd0226e0 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_failure_indicator.tsx @@ -0,0 +1,31 @@ +/* + * 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, EuiFlexGroup, EuiFlexItem, EuiTextColor } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const CategoryExampleMessagesFailureIndicator: React.FunctionComponent<{ + onRetry: () => void; +}> = ({ onRetry }) => ( + + + + + + + + + + + + +); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx new file mode 100644 index 0000000000000..cad87a96a1326 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_messages_loading_indicator.tsx @@ -0,0 +1,18 @@ +/* + * 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 { EuiLoadingContent } from '@elastic/eui'; +import React from 'react'; + +export const CategoryExampleMessagesLoadingIndicator: React.FunctionComponent<{ + exampleCount: number; +}> = ({ exampleCount }) => ( + <> + {Array.from(new Array(exampleCount), (_value, index) => ( + + ))} + +); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx new file mode 100644 index 0000000000000..a3705cb28ed05 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx @@ -0,0 +1,35 @@ +/* + * 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 React from 'react'; + +import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { AnalyzeCategoryDatasetInMlAction } from './analyze_dataset_in_ml_action'; + +export const DatasetActionsList: React.FunctionComponent<{ + categorizationJobId: string; + categoryId: number; + datasets: LogEntryCategoryDataset[]; + timeRange: TimeRange; +}> = ({ categorizationJobId, categoryId, datasets, timeRange }) => ( +
    + {datasets.map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset.name); + return ( +
  • + +
  • + ); + })} +
+); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx index c30612f54be00..6918ae0914cc6 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx @@ -6,15 +6,31 @@ import React from 'react'; +import { euiStyled } from '../../../../../../../observability/public'; +import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; export const DatasetsList: React.FunctionComponent<{ - datasets: string[]; + datasets: LogEntryCategoryDataset[]; }> = ({ datasets }) => (
    - {datasets.sort().map(dataset => { - const datasetLabel = getFriendlyNameForPartitionId(dataset); - return
  • {datasetLabel}
  • ; + {datasets.map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset.name); + return ( +
  • + {datasetLabel} +
  • + ); })}
); + +/* + * These aim at aligning the list with the EuiHealth list in the neighboring + * column. + */ +const DatasetLabel = euiStyled.div` + display: inline-block; + margin-bottom: 2.5px; + margin-top: 1px; +`; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx index 962b506536253..37d26de6fce70 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -25,6 +25,7 @@ export const TopCategoriesSection: React.FunctionComponent<{ onChangeDatasetSelection: (datasets: string[]) => void; onRequestRecreateMlJob: () => void; selectedDatasets: string[]; + sourceId: string; timeRange: TimeRange; topCategories: LogEntryCategory[]; }> = ({ @@ -35,6 +36,7 @@ export const TopCategoriesSection: React.FunctionComponent<{ onChangeDatasetSelection, onRequestRecreateMlJob, selectedDatasets, + sourceId, timeRange, topCategories, }) => { @@ -67,7 +69,12 @@ export const TopCategoriesSection: React.FunctionComponent<{ isLoading={isLoadingTopCategories} loadingChildren={} > - + ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx index a2bd2983092a0..811dcba7ff3ef 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -8,33 +8,76 @@ import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; +import { useSet } from 'react-use'; import { euiStyled } from '../../../../../../../observability/public'; import { LogEntryCategory, + LogEntryCategoryDataset, LogEntryCategoryHistogram, } from '../../../../../../common/http_api/log_analysis'; import { TimeRange } from '../../../../../../common/http_api/shared'; -import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { RowExpansionButton } from '../../../../../components/basic_table'; +import { AnomalySeverityIndicatorList } from './anomaly_severity_indicator_list'; +import { CategoryDetailsRow } from './category_details_row'; import { RegularExpressionRepresentation } from './category_expression'; +import { DatasetActionsList } from './datasets_action_list'; import { DatasetsList } from './datasets_list'; import { LogEntryCountSparkline } from './log_entry_count_sparkline'; export const TopCategoriesTable = euiStyled( ({ + categorizationJobId, className, + sourceId, timeRange, topCategories, }: { + categorizationJobId: string; className?: string; + sourceId: string; timeRange: TimeRange; topCategories: LogEntryCategory[]; }) => { - const columns = useMemo(() => createColumns(timeRange), [timeRange]); + const [expandedCategories, { add: expandCategory, remove: collapseCategory }] = useSet( + new Set() + ); + + const columns = useMemo( + () => + createColumns( + timeRange, + categorizationJobId, + expandedCategories, + expandCategory, + collapseCategory + ), + [categorizationJobId, collapseCategory, expandCategory, expandedCategories, timeRange] + ); + + const expandedRowContentsById = useMemo( + () => + [...expandedCategories].reduce>( + (aggregatedCategoryRows, categoryId) => ({ + ...aggregatedCategoryRows, + [categoryId]: ( + + ), + }), + {} + ), + [expandedCategories, sourceId, timeRange] + ); return ( @@ -46,7 +89,13 @@ export const TopCategoriesTable = euiStyled( } `; -const createColumns = (timeRange: TimeRange): Array> => [ +const createColumns = ( + timeRange: TimeRange, + categorizationJobId: string, + expandedCategories: Set, + expandCategory: (categoryId: number) => void, + collapseCategory: (categoryId: number) => void +): Array> => [ { align: 'right', field: 'logEntryCount', @@ -89,7 +138,7 @@ const createColumns = (timeRange: TimeRange): Array , + render: (datasets: LogEntryCategoryDataset[]) => , width: '200px', }, { @@ -98,9 +147,40 @@ const createColumns = (timeRange: TimeRange): Array ( - + render: (_maximumAnomalyScore: number, item) => ( + ), width: '160px', }, + { + actions: [ + { + render: category => ( + + ), + }, + ], + width: '40px', + }, + + { + align: 'right', + isExpander: true, + render: (item: LogEntryCategory) => { + return ( + + ); + }, + width: '40px', + }, ]; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_examples.ts new file mode 100644 index 0000000000000..a10d077a2dd4f --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_examples.ts @@ -0,0 +1,47 @@ +/* + * 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 { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from '../../../../legacy_singletons'; + +import { + getLogEntryCategoryExamplesRequestPayloadRT, + getLogEntryCategoryExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryCategoryExamplesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + categoryId: number, + exampleCount: number +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoryExamplesRequestPayloadRT.encode({ + data: { + categoryId, + exampleCount, + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + }); + + return pipe( + getLogEntryCategoryExamplesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_category_examples.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_category_examples.tsx new file mode 100644 index 0000000000000..cdf3b642a8012 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_category_examples.tsx @@ -0,0 +1,65 @@ +/* + * 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 { useMemo, useState } from 'react'; + +import { LogEntryCategoryExample } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryCategoryExamplesAPI } from './service_calls/get_log_entry_category_examples'; + +export const useLogEntryCategoryExamples = ({ + categoryId, + endTime, + exampleCount, + sourceId, + startTime, +}: { + categoryId: number; + endTime: number; + exampleCount: number; + sourceId: string; + startTime: number; +}) => { + const [logEntryCategoryExamples, setLogEntryCategoryExamples] = useState< + LogEntryCategoryExample[] + >([]); + + const [getLogEntryCategoryExamplesRequest, getLogEntryCategoryExamples] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryCategoryExamplesAPI( + sourceId, + startTime, + endTime, + categoryId, + exampleCount + ); + }, + onResolve: ({ data: { examples } }) => { + setLogEntryCategoryExamples(examples); + }, + }, + [categoryId, endTime, exampleCount, sourceId, startTime] + ); + + const isLoadingLogEntryCategoryExamples = useMemo( + () => getLogEntryCategoryExamplesRequest.state === 'pending', + [getLogEntryCategoryExamplesRequest.state] + ); + + const hasFailedLoadingLogEntryCategoryExamples = useMemo( + () => getLogEntryCategoryExamplesRequest.state === 'rejected', + [getLogEntryCategoryExamplesRequest.state] + ); + + return { + getLogEntryCategoryExamples, + hasFailedLoadingLogEntryCategoryExamples, + isLoadingLogEntryCategoryExamples, + logEntryCategoryExamples, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 39d76c4afcffe..6eaa5de900080 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,19 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; +import { EuiBasicTable } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; +import { euiStyled } from '../../../../../../../observability/public'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { formatAnomalyScore, getFriendlyNameForPartitionId, } from '../../../../../../common/log_analysis'; +import { RowExpansionButton } from '../../../../../components/basic_table'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; -import { euiStyled } from '../../../../../../../observability/public'; interface TableItem { id: string; @@ -31,14 +32,6 @@ interface SortingOptions { }; } -const collapseAriaLabel = i18n.translate('xpack.infra.logs.analysis.anomaliesTableCollapseLabel', { - defaultMessage: 'Collapse', -}); - -const expandAriaLabel = i18n.translate('xpack.infra.logs.analysis.anomaliesTableExpandLabel', { - defaultMessage: 'Expand', -}); - const partitionColumnName = i18n.translate( 'xpack.infra.logs.analysis.anomaliesTablePartitionColumnName', { @@ -106,29 +99,34 @@ export const AnomaliesTable: React.FunctionComponent<{ return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); }, [tableItems, sorting]); - const toggleExpandedItems = useCallback( + const expandItem = useCallback( + item => { + const newItemIdToExpandedRowMap = { + ...itemIdToExpandedRowMap, + [item.id]: ( + + ), + }; + setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); + }, + [itemIdToExpandedRowMap, jobId, results, setTimeRange, timeRange] + ); + + const collapseItem = useCallback( item => { if (itemIdToExpandedRowMap[item.id]) { const { [item.id]: toggledItem, ...remainingExpandedRowMap } = itemIdToExpandedRowMap; setItemIdToExpandedRowMap(remainingExpandedRowMap); - } else { - const newItemIdToExpandedRowMap = { - ...itemIdToExpandedRowMap, - [item.id]: ( - - ), - }; - setItemIdToExpandedRowMap(newItemIdToExpandedRowMap); } }, - [itemIdToExpandedRowMap, jobId, results, setTimeRange, timeRange] + [itemIdToExpandedRowMap] ); const columns = [ @@ -150,10 +148,11 @@ export const AnomaliesTable: React.FunctionComponent<{ width: '40px', isExpander: true, render: (item: TableItem) => ( - toggleExpandedItems(item)} - aria-label={itemIdToExpandedRowMap[item.id] ? collapseAriaLabel : expandAriaLabel} - iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} + ), }, diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 4f290cb05f056..f058b9e52c75b 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -14,6 +14,7 @@ import { InfraBackendLibs } from './lib/infra_types'; import { initGetLogEntryCategoriesRoute, initGetLogEntryCategoryDatasetsRoute, + initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -45,6 +46,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initIpToHostName(libs); initGetLogEntryCategoriesRoute(libs); initGetLogEntryCategoryDatasetsRoute(libs); + initGetLogEntryCategoryExamplesRoute(libs); initGetLogEntryRateRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index d507720c03669..a9e6c701d2988 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -38,6 +38,8 @@ export interface CallWithRequestParams extends GenericParams { size?: number; terminate_after?: number; fields?: string | string[]; + path?: string; + query?: string | object; } export type InfraResponse = Lifecycle.ReturnValue; diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index 7c12e23d7e903..6ff749c040220 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -177,6 +177,11 @@ export class KibanaFramework { method: 'indices.get' | 'ml.getBuckets', options?: object ): Promise; + callWithRequest( + requestContext: RequestHandlerContext, + method: 'transport.request', + options?: CallWithRequestParams + ): Promise; callWithRequest( requestContext: RequestHandlerContext, endpoint: string, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts index d1c8316ad061b..e07126416f4ce 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts @@ -4,9 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable max-classes-per-file */ + export class NoLogAnalysisResultsIndexError extends Error { constructor(message?: string) { super(message); Object.setPrototypeOf(this, new.target.prototype); } } + +export class NoLogAnalysisMlJobError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class InsufficientLogAnalysisMlJobConfigurationError extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class UnknownCategoryError extends Error { + constructor(categoryId: number) { + super(`Unknown ml category ${categoryId}`); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 2e270f16ca867..f17d665e209ec 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -5,16 +5,30 @@ */ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { getJobId, logEntryCategoriesJobTypes } from '../../../common/log_analysis'; +import { + compareDatasetsByMaximumAnomalyScore, + getJobId, + jobCustomSettingsRT, + logEntryCategoriesJobTypes, +} from '../../../common/log_analysis'; import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { NoLogAnalysisResultsIndexError } from './errors'; +import { + InsufficientLogAnalysisMlJobConfigurationError, + NoLogAnalysisMlJobError, + NoLogAnalysisResultsIndexError, + UnknownCategoryError, +} from './errors'; import { createLogEntryCategoriesQuery, logEntryCategoriesResponseRT, LogEntryCategoryHit, } from './queries/log_entry_categories'; +import { + createLogEntryCategoryExamplesQuery, + logEntryCategoryExamplesResponseRT, +} from './queries/log_entry_category_examples'; import { createLogEntryCategoryHistogramsQuery, logEntryCategoryHistogramsResponseRT, @@ -25,6 +39,7 @@ import { LogEntryDatasetBucket, logEntryDatasetsResponseRT, } from './queries/log_entry_data_sets'; +import { createMlJobsQuery, mlJobsResponseRT } from './queries/ml_jobs'; import { createTopLogEntryCategoriesQuery, topLogEntryCategoriesResponseRT, @@ -175,6 +190,80 @@ export class LogEntryCategoriesAnalysis { }; } + public async getLogEntryCategoryExamples( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number, + categoryId: number, + exampleCount: number + ) { + const finalizeLogEntryCategoryExamplesSpan = startTracingSpan( + 'get category example log entries' + ); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await this.fetchMlJob(requestContext, logEntryCategoriesCountJobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${logEntryCategoriesCountJobId}` + ); + } + + const { + logEntryCategoriesById, + timing: { spans: fetchLogEntryCategoriesSpans }, + } = await this.fetchLogEntryCategories(requestContext, logEntryCategoriesCountJobId, [ + categoryId, + ]); + const category = logEntryCategoriesById[categoryId]; + + if (category == null) { + throw new UnknownCategoryError(categoryId); + } + + const { + examples, + timing: { spans: fetchLogEntryCategoryExamplesSpans }, + } = await this.fetchLogEntryCategoryExamples( + requestContext, + indices, + timestampField, + startTime, + endTime, + category._source.terms, + exampleCount + ); + + const logEntryCategoryExamplesSpan = finalizeLogEntryCategoryExamplesSpan(); + + return { + data: examples, + timing: { + spans: [ + logEntryCategoryExamplesSpan, + ...fetchMlJobSpans, + ...fetchLogEntryCategoriesSpans, + ...fetchLogEntryCategoryExamplesSpans, + ], + }, + }; + } + private async fetchTopLogEntryCategories( requestContext: RequestHandlerContext, logEntryCategoriesCountJobId: string, @@ -208,14 +297,30 @@ export class LogEntryCategoriesAnalysis { } const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( - topCategoryBucket => ({ - categoryId: parseCategoryId(topCategoryBucket.key), - logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, - datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets.map( - datasetBucket => datasetBucket.key - ), - maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, - }) + topCategoryBucket => { + const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< + Record + >( + (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ + ...accumulatedMaximumAnomalyScores, + [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, + }), + {} + ); + + return { + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets + .map(datasetBucket => ({ + name: datasetBucket.key, + maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, + })) + .sort(compareDatasetsByMaximumAnomalyScore) + .reverse(), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }; + } ); return { @@ -351,6 +456,78 @@ export class LogEntryCategoriesAnalysis { }, }; } + + private async fetchMlJob( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string + ) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); + + const { + jobs: [mlJob], + } = decodeOrThrow(mlJobsResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'transport.request', + createMlJobsQuery([logEntryCategoriesCountJobId]) + ) + ); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`); + } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; + } + + private async fetchLogEntryCategoryExamples( + requestContext: RequestHandlerContext, + indices: string, + timestampField: string, + startTime: number, + endTime: number, + categoryQuery: string, + exampleCount: number + ) { + const finalizeEsSearchSpan = startTracingSpan('Fetch examples from ES'); + + const { + hits: { hits }, + } = decodeOrThrow(logEntryCategoryExamplesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryCategoryExamplesQuery( + indices, + timestampField, + startTime, + endTime, + categoryQuery, + exampleCount + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map(hit => ({ + dataset: hit._source.event?.dataset ?? '', + message: hit._source.message ?? '', + timestamp: hit.sort[0], + })), + timing: { + spans: [esSearchSpan], + }, + }; + } } const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts index 92ef4fb4e35c9..f1e68d34fdae3 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -35,3 +35,11 @@ export const createResultTypeFilters = (resultType: 'model_plot' | 'record') => }, }, ]; + +export const createCategoryIdFilters = (categoryIds: number[]) => [ + { + terms: { + category_id: categoryIds, + }, + }, +]; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts index 63b3632f03784..2681a4c037f5d 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; -import { defaultRequestParameters, getMlResultIndex } from './common'; +import { defaultRequestParameters, getMlResultIndex, createCategoryIdFilters } from './common'; export const createLogEntryCategoriesQuery = ( logEntryCategoriesJobId: string, @@ -17,16 +17,10 @@ export const createLogEntryCategoriesQuery = ( body: { query: { bool: { - filter: [ - { - terms: { - category_id: categoryIds, - }, - }, - ], + filter: [...createCategoryIdFilters(categoryIds)], }, }, - _source: ['category_id', 'regex'], + _source: ['category_id', 'regex', 'terms'], }, index: getMlResultIndex(logEntryCategoriesJobId), size: categoryIds.length, @@ -36,6 +30,7 @@ export const logEntryCategoryHitRT = rt.type({ _source: rt.type({ category_id: rt.number, regex: rt.string, + terms: rt.string, }), }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts new file mode 100644 index 0000000000000..c4c7efcfc4ff0 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts @@ -0,0 +1,78 @@ +/* + * 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 * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters } from './common'; + +export const createLogEntryCategoryExamplesQuery = ( + indices: string, + timestampField: string, + startTime: number, + endTime: number, + categoryQuery: string, + exampleCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: startTime, + lte: endTime, + }, + }, + }, + { + match: { + message: { + query: categoryQuery, + operator: 'AND', + }, + }, + }, + ], + }, + }, + sort: [ + { + [timestampField]: { + order: 'asc', + }, + }, + ], + }, + _source: ['event.dataset', 'message'], + index: indices, + size: exampleCount, +}); + +export const logEntryCategoryExampleHitRT = rt.type({ + _source: rt.partial({ + event: rt.partial({ + dataset: rt.string, + }), + message: rt.string, + }), + sort: rt.tuple([rt.number]), +}); + +export type LogEntryCategoryExampleHit = rt.TypeOf; + +export const logEntryCategoryExamplesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryCategoryExampleHitRT), + }), + }), +]); + +export type logEntryCategoryExamplesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/ml_jobs.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/ml_jobs.ts new file mode 100644 index 0000000000000..ee4ccbfaeb5a7 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/ml_jobs.ts @@ -0,0 +1,24 @@ +/* + * 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 * as rt from 'io-ts'; + +export const createMlJobsQuery = (jobIds: string[]) => ({ + method: 'GET', + path: `/_ml/anomaly_detectors/${jobIds.join(',')}`, + query: { + allow_no_jobs: true, + }, +}); + +export const mlJobRT = rt.type({ + job_id: rt.string, + custom_settings: rt.unknown, +}); + +export const mlJobsResponseRT = rt.type({ + jobs: rt.array(mlJobRT), +}); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 22b0ef748f5f8..517d31865e358 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -100,6 +100,19 @@ export const createTopLogEntryCategoriesQuery = ( field: 'record_score', }, }, + terms_dataset: { + terms: { + field: 'partition_field_value', + size: 1000, + }, + aggs: { + maximum_record_score: { + max: { + field: 'record_score', + }, + }, + }, + }, }, }, }, @@ -130,6 +143,15 @@ export const logEntryCategoryBucketRT = rt.type({ doc_count: rt.number, filter_record: rt.type({ maximum_record_score: metricAggregationRT, + terms_dataset: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + doc_count: rt.number, + maximum_record_score: metricAggregationRT, + }) + ), + }), }), filter_model_plot: rt.type({ sum_actual: metricAggregationRT, diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index d9ca9a96ffe51..15615046bdd6a 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -6,4 +6,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; +export * from './log_entry_category_examples'; export * from './log_entry_rate'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts new file mode 100644 index 0000000000000..67c6c9f5b9924 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts @@ -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 { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + getLogEntryCategoryExamplesRequestPayloadRT, + getLogEntryCategoryExamplesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoryExamplesRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + categoryId, + exampleCount, + sourceId, + timeRange: { startTime, endTime }, + }, + } = pipe( + getLogEntryCategoryExamplesRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: logEntryCategoryExamples, + timing, + } = await logEntryCategoriesAnalysis.getLogEntryCategoryExamples( + requestContext, + request, + sourceId, + startTime, + endTime, + categoryId, + exampleCount + ); + + return response.ok({ + body: getLogEntryCategoryExamplesSuccessReponsePayloadRT.encode({ + data: { + examples: logEntryCategoryExamples, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a28add39f2118..5a511edd4c735 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6387,8 +6387,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", - "xpack.infra.logs.analysis.anomaliesTableCollapseLabel": "縮小", - "xpack.infra.logs.analysis.anomaliesTableExpandLabel": "拡張", "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最高異常スコア", "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "パーティション", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "異常が検出されませんでした。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 30e89b525e5d5..fb75f22f0511e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6387,8 +6387,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", - "xpack.infra.logs.analysis.anomaliesTableCollapseLabel": "折叠", - "xpack.infra.logs.analysis.anomaliesTableExpandLabel": "展开", "xpack.infra.logs.analysis.anomaliesTableMaxAnomalyScoreColumnName": "最大异常分数", "xpack.infra.logs.analysis.anomaliesTablePartitionColumnName": "分区", "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "未检测到任何异常。",