[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.
This commit is contained in:
Felix Stürmer 2020-03-11 20:07:55 +01:00 committed by GitHub
parent 3c64031c07
commit 07a7caaf2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1365 additions and 176 deletions

View file

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

View file

@ -72,9 +72,16 @@ export const logEntryCategoryHistogramRT = rt.type({
export type LogEntryCategoryHistogram = rt.TypeOf<typeof logEntryCategoryHistogramRT>;
export const logEntryCategoryDatasetRT = rt.type({
name: rt.string,
maximumAnomalyScore: rt.number,
});
export type LogEntryCategoryDataset = rt.TypeOf<typeof logEntryCategoryDatasetRT>;
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,

View file

@ -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<typeof logEntryCategoryExampleRT>;
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
>;

View file

@ -28,3 +28,10 @@ export const jobSourceConfigurationRT = rt.type({
});
export type JobSourceConfiguration = rt.TypeOf<typeof jobSourceConfigurationRT>;
export const jobCustomSettingsRT = rt.partial({
job_revision: rt.number,
logs_source_config: rt.partial(jobSourceConfigurationRT.props),
});
export type JobCustomSettings = rt.TypeOf<typeof jobCustomSettingsRT>;

View file

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

View file

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

View file

@ -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 = <Item extends any>({
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 (
<EuiButtonIcon
onClick={handleClick}
aria-label={isExpanded ? collapseAriaLabel : expandAriaLabel}
iconType={isExpanded ? 'arrowUp' : 'arrowDown'}
/>
);
};
const collapseAriaLabel = i18n.translate('xpack.infra.table.collapseRowLabel', {
defaultMessage: 'Collapse',
});
const expandAriaLabel = i18n.translate('xpack.infra.table.expandRowLabel', {
defaultMessage: 'Expand',
});

View file

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

View file

@ -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<string, string>
): LinkDescriptor => {
const { from, to } = convertTimeRangeToParams(timeRange);
@ -81,7 +83,7 @@ const getPartitionSpecificSingleMetricViewerLinkDescriptor = (
const _a = encode({
mlTimeSeriesExplorer: {
entities: { 'event.dataset': partition },
entities,
},
});

View file

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

View file

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

View file

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

View file

@ -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<LogEntryFieldColumnProps> = ({
@ -33,7 +39,7 @@ export const LogEntryFieldColumn: React.FunctionComponent<LogEntryFieldColumnPro
isActiveHighlight,
isHighlighted,
isHovered,
isWrapped,
wrapMode,
}) => {
const value = useMemo(() => (isFieldColumn(columnValue) ? JSON.parse(columnValue.value) : null), [
columnValue,
@ -59,30 +65,12 @@ export const LogEntryFieldColumn: React.FunctionComponent<LogEntryFieldColumnPro
);
return (
<FieldColumnContent isHighlighted={isHighlighted} isHovered={isHovered} isWrapped={isWrapped}>
<FieldColumnContent isHighlighted={isHighlighted} isHovered={isHovered} wrapMode={wrapMode}>
{formattedValue}
</FieldColumnContent>
);
};
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)<LogEntryColumnContentProps>`
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};
`;

View file

@ -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<LogEntryMessageColumnProps>(
({ 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<LogEntryMessageColumnProps>(
);
return (
<MessageColumnContent
isHighlighted={isHighlighted}
isHovered={isHovered}
isWrapped={isWrapped}
>
<MessageColumnContent isHighlighted={isHighlighted} isHovered={isHovered} wrapMode={wrapMode}>
{message}
</MessageColumnContent>
);
}
);
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)<MessageColumnContentProps>`
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 = (

View file

@ -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}
</LogEntryColumn>
@ -171,7 +170,7 @@ export const LogEntryRow = memo(
isActiveHighlight={isActiveHighlight}
isHighlighted={isHighlighted}
isHovered={isHovered}
isWrapped={wrap}
wrapMode={wrap ? 'long' : 'pre-wrapped'}
/>
) : null}
</LogEntryColumn>
@ -197,7 +196,7 @@ interface LogEntryRowWrapperProps {
scale: TextScale;
}
const LogEntryRowWrapper = euiStyled.div.attrs(() => ({
export const LogEntryRowWrapper = euiStyled.div.attrs(() => ({
role: 'row',
}))<LogEntryRowWrapperProps>`
align-items: stretch;

View file

@ -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<LogEntryTimestampColumnProps>(
({ isHighlighted, isHovered, time }) => {
const formattedTime = useFormattedTime(time, { format: 'time' });
({ format = 'time', isHighlighted, isHovered, time }) => {
const formattedTime = useFormattedTime(time, { format });
return (
<TimestampColumnContent isHovered={isHovered} isHighlighted={isHighlighted}>

View file

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

View file

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

View file

@ -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,

View file

@ -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 <JobType extends string>(
spaceId: string,

View file

@ -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}`, {

View file

@ -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<typeof setupMlModuleJobOverridesRT>;

View file

@ -197,6 +197,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => {
onChangeDatasetSelection={setCategoryQueryDatasets}
onRequestRecreateMlJob={viewSetupForReconfiguration}
selectedDatasets={categoryQueryDatasets}
sourceId={sourceId}
timeRange={categoryQueryTimeRange.timeRange}
topCategories={topLogEntryCategories}
/>

View file

@ -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 (
<EuiToolTip content={analyseCategoryDatasetInMlTooltipDescription} delay="long">
<EuiButtonIcon
aria-label={analyseCategoryDatasetInMlButtonLabel}
iconType="machineLearningApp"
data-test-subj="analyzeCategoryDatasetInMlButton"
{...linkProps}
/>
</EuiToolTip>
);
};
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.' }
);

View file

@ -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 }) => (
<ul>
{datasets.map(dataset => {
const datasetLabel = getFriendlyNameForPartitionId(dataset.name);
return (
<li key={datasetLabel}>
<AnomalySeverityIndicator anomalyScore={dataset.maximumAnomalyScore} />
</li>
);
})}
</ul>
);

View file

@ -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 (
<CategoryExampleMessages>
{isLoadingLogEntryCategoryExamples ? (
<CategoryExampleMessagesLoadingIndicator exampleCount={exampleCount} />
) : hasFailedLoadingLogEntryCategoryExamples ? (
<CategoryExampleMessagesFailureIndicator onRetry={getLogEntryCategoryExamples} />
) : logEntryCategoryExamples.length === 0 ? (
<CategoryExampleMessagesEmptyIndicator onReload={getLogEntryCategoryExamples} />
) : (
logEntryCategoryExamples.map((categoryExample, categoryExampleIndex) => (
<CategoryExampleMessage
dataset={categoryExample.dataset}
key={categoryExampleIndex}
message={categoryExample.message}
timestamp={categoryExample.timestamp}
/>
))
)}
</CategoryExampleMessages>
);
};
const CategoryExampleMessages = euiStyled.div`
align-items: stretch;
flex-direction: column;
flex: 1 0 0%;
overflow: hidden;
`;

View file

@ -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 (
<LogEntryRowWrapper scale={exampleMessageScale}>
<LogEntryColumn {...columnWidths[timestampColumnId]}>
<LogEntryTimestampColumn
format={exampleTimestampFormat}
isHighlighted={false}
isHovered={false}
time={timestamp}
/>
</LogEntryColumn>
<LogEntryColumn {...columnWidths[messageColumnId]}>
<LogEntryMessageColumn
columnValue={{
__typename: 'InfraLogEntryMessageColumn' as const,
columnId: messageColumnId,
message: [
{ __typename: 'InfraLogMessageFieldSegment', field: 'message', value: message },
],
}}
highlights={noHighlights}
isHovered={false}
isHighlighted={false}
isActiveHighlight={false}
wrapMode="none"
/>
</LogEntryColumn>
<LogEntryColumn {...columnWidths[datasetColumnId]}>
<LogEntryFieldColumn
columnValue={{
__typename: 'InfraLogEntryFieldColumn' as const,
columnId: datasetColumnId,
field: 'event.dataset',
value: encodedDatasetFieldValue,
}}
highlights={noHighlights}
isHovered={false}
isHighlighted={false}
isActiveHighlight={false}
wrapMode="none"
/>
</LogEntryColumn>
</LogEntryRowWrapper>
);
};
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,
},
},
];

View file

@ -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 }) => (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false} className="eui-textNoWrap">
<FormattedMessage
id="xpack.infra.logs.logEntryCategories.exampleEmptyDescription"
defaultMessage="No examples found within the selected time range. Increase the log entry retention period to improve message sample availability."
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onReload} size="s">
<FormattedMessage
id="xpack.infra.logs.logEntryCategories.exampleEmptyReloadButtonLabel"
defaultMessage="Reload"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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 }) => (
<EuiFlexGroup alignItems="center" justifyContent="center">
<EuiFlexItem grow={false} className="eui-textNoWrap">
<EuiTextColor color="danger">
<FormattedMessage
id="xpack.infra.logs.logEntryCategories.exampleLoadingFailureDescription"
defaultMessage="Failed to load category examples."
/>
</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onRetry} size="s">
<FormattedMessage
id="xpack.infra.logs.logEntryCategories.exampleLoadingFailureRetryButtonLabel"
defaultMessage="Retry"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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) => (
<EuiLoadingContent key={index} lines={1} />
))}
</>
);

View file

@ -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 }) => (
<ul>
{datasets.map(dataset => {
const datasetLabel = getFriendlyNameForPartitionId(dataset.name);
return (
<li key={datasetLabel}>
<AnalyzeCategoryDatasetInMlAction
categorizationJobId={categorizationJobId}
categoryId={categoryId}
dataset={dataset.name}
timeRange={timeRange}
/>
</li>
);
})}
</ul>
);

View file

@ -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 }) => (
<ul>
{datasets.sort().map(dataset => {
const datasetLabel = getFriendlyNameForPartitionId(dataset);
return <li key={datasetLabel}>{datasetLabel}</li>;
{datasets.map(dataset => {
const datasetLabel = getFriendlyNameForPartitionId(dataset.name);
return (
<li key={datasetLabel}>
<DatasetLabel>{datasetLabel}</DatasetLabel>
</li>
);
})}
</ul>
);
/*
* 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;
`;

View file

@ -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={<LoadingOverlayContent />}
>
<TopCategoriesTable timeRange={timeRange} topCategories={topCategories} />
<TopCategoriesTable
categorizationJobId={jobId}
sourceId={sourceId}
timeRange={timeRange}
topCategories={topCategories}
/>
</LoadingOverlayWrapper>
</>
);

View file

@ -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<number>(
new Set()
);
const columns = useMemo(
() =>
createColumns(
timeRange,
categorizationJobId,
expandedCategories,
expandCategory,
collapseCategory
),
[categorizationJobId, collapseCategory, expandCategory, expandedCategories, timeRange]
);
const expandedRowContentsById = useMemo(
() =>
[...expandedCategories].reduce<Record<number, React.ReactNode>>(
(aggregatedCategoryRows, categoryId) => ({
...aggregatedCategoryRows,
[categoryId]: (
<CategoryDetailsRow
categoryId={categoryId}
sourceId={sourceId}
timeRange={timeRange}
/>
),
}),
{}
),
[expandedCategories, sourceId, timeRange]
);
return (
<EuiBasicTable
columns={columns}
itemIdToExpandedRowMap={expandedRowContentsById}
itemId="categoryId"
items={topCategories}
rowProps={{ className: `${className} euiTableRow--topAligned` }}
/>
@ -46,7 +89,13 @@ export const TopCategoriesTable = euiStyled(
}
`;
const createColumns = (timeRange: TimeRange): Array<EuiBasicTableColumn<LogEntryCategory>> => [
const createColumns = (
timeRange: TimeRange,
categorizationJobId: string,
expandedCategories: Set<number>,
expandCategory: (categoryId: number) => void,
collapseCategory: (categoryId: number) => void
): Array<EuiBasicTableColumn<LogEntryCategory>> => [
{
align: 'right',
field: 'logEntryCount',
@ -89,7 +138,7 @@ const createColumns = (timeRange: TimeRange): Array<EuiBasicTableColumn<LogEntry
name: i18n.translate('xpack.infra.logs.logEntryCategories.datasetColumnTitle', {
defaultMessage: 'Datasets',
}),
render: (datasets: string[]) => <DatasetsList datasets={datasets} />,
render: (datasets: LogEntryCategoryDataset[]) => <DatasetsList datasets={datasets} />,
width: '200px',
},
{
@ -98,9 +147,40 @@ const createColumns = (timeRange: TimeRange): Array<EuiBasicTableColumn<LogEntry
name: i18n.translate('xpack.infra.logs.logEntryCategories.maximumAnomalyScoreColumnTitle', {
defaultMessage: 'Maximum anomaly score',
}),
render: (maximumAnomalyScore: number) => (
<AnomalySeverityIndicator anomalyScore={maximumAnomalyScore} />
render: (_maximumAnomalyScore: number, item) => (
<AnomalySeverityIndicatorList datasets={item.datasets} />
),
width: '160px',
},
{
actions: [
{
render: category => (
<DatasetActionsList
categorizationJobId={categorizationJobId}
categoryId={category.categoryId}
datasets={category.datasets}
timeRange={timeRange}
/>
),
},
],
width: '40px',
},
{
align: 'right',
isExpander: true,
render: (item: LogEntryCategory) => {
return (
<RowExpansionButton
isExpanded={expandedCategories.has(item.categoryId)}
item={item.categoryId}
onCollapse={collapseCategory}
onExpand={expandCategory}
/>
);
},
width: '40px',
},
];

View file

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

View file

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

View file

@ -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]: (
<AnomaliesTableExpandedRow
partitionId={item.partitionId}
results={results}
topAnomalyScore={item.topAnomalyScore}
setTimeRange={setTimeRange}
timeRange={timeRange}
jobId={jobId}
/>
),
};
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]: (
<AnomaliesTableExpandedRow
partitionId={item.partitionId}
results={results}
topAnomalyScore={item.topAnomalyScore}
setTimeRange={setTimeRange}
timeRange={timeRange}
jobId={jobId}
/>
),
};
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) => (
<EuiButtonIcon
onClick={() => toggleExpandedItems(item)}
aria-label={itemIdToExpandedRowMap[item.id] ? collapseAriaLabel : expandAriaLabel}
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
<RowExpansionButton
isExpanded={item.id in itemIdToExpandedRowMap}
item={item.id}
onExpand={expandItem}
onCollapse={collapseItem}
/>
),
},

View file

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

View file

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

View file

@ -177,6 +177,11 @@ export class KibanaFramework {
method: 'indices.get' | 'ml.getBuckets',
options?: object
): Promise<InfraDatabaseGetIndicesResponse>;
callWithRequest(
requestContext: RequestHandlerContext,
method: 'transport.request',
options?: CallWithRequestParams
): Promise<unknown>;
callWithRequest(
requestContext: RequestHandlerContext,
endpoint: string,

View file

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

View file

@ -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<string, number>
>(
(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);

View file

@ -35,3 +35,11 @@ export const createResultTypeFilters = (resultType: 'model_plot' | 'record') =>
},
},
];
export const createCategoryIdFilters = (categoryIds: number[]) => [
{
terms: {
category_id: categoryIds,
},
},
];

View file

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

View file

@ -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<typeof logEntryCategoryExampleHitRT>;
export const logEntryCategoryExamplesResponseRT = rt.intersection([
commonSearchSuccessResponseFieldsRT,
rt.type({
hits: rt.type({
hits: rt.array(logEntryCategoryExampleHitRT),
}),
}),
]);
export type logEntryCategoryExamplesResponse = rt.TypeOf<typeof logEntryCategoryExamplesResponseRT>;

View file

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

View file

@ -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,

View file

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

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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 },
});
}
}
);
};

View file

@ -6386,8 +6386,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": "異常が検出されませんでした。",

View file

@ -6386,8 +6386,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": "未检测到任何异常。",