[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:
parent
3c64031c07
commit
07a7caaf2f
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
>;
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
|
@ -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',
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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};
|
||||
`;
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}`, {
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -197,6 +197,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => {
|
|||
onChangeDatasetSelection={setCategoryQueryDatasets}
|
||||
onRequestRecreateMlJob={viewSetupForReconfiguration}
|
||||
selectedDatasets={categoryQueryDatasets}
|
||||
sourceId={sourceId}
|
||||
timeRange={categoryQueryTimeRange.timeRange}
|
||||
topCategories={topLogEntryCategories}
|
||||
/>
|
||||
|
|
|
@ -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.' }
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
`;
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -35,3 +35,11 @@ export const createResultTypeFilters = (resultType: 'model_plot' | 'record') =>
|
|||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const createCategoryIdFilters = (categoryIds: number[]) => [
|
||||
{
|
||||
terms: {
|
||||
category_id: categoryIds,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
@ -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>;
|
|
@ -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),
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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": "異常が検出されませんでした。",
|
||||
|
|
|
@ -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": "未检测到任何异常。",
|
||||
|
|
Loading…
Reference in a new issue