[AIOps] Explain Log Rate Spikes: create shareable component containing only analysis (#158629)

## Summary

This PR exposes the `ExplainLogRateSpikesContent` shared component so
that it can be used independently of the search bar/datepicker
- The component accepts various external settings including a timerange
and query to run the analysis against.
- The `ExplainLogRateSpikesPage` component now uses the
`ExplainLogRateSpikesContent` component
- The `useData` hook has been simplified - the set up for the search
query has been extracted into a separate hook

<img width="1245" alt="image"
src="30dec4b2-3162-4a39-b598-0dec70993fa7">

This is the first step for the Observability Alert Details Page
Integration.

Style edits:

The component now uses EUI's ResizeableContainer to allow the main
histogram to be sticky.
Also adds some style updates from
https://github.com/elastic/kibana/issues/156605


### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Melissa Alvarez 2023-05-30 13:44:59 -06:00 committed by GitHub
parent 0ff50e14cd
commit 03d4fe7515
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 616 additions and 310 deletions

View file

@ -12,6 +12,7 @@ import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiIconTip,
EuiProgress,
EuiText,
@ -45,11 +46,69 @@ export const ProgressControls: FC<ProgressControlProps> = ({
}) => {
const { euiTheme } = useEuiTheme();
const runningProgressBarStyles = useAnimatedProgressBarBackground(euiTheme.colors.success);
const analysisCompleteStyle = { display: 'none' };
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
{!isRunning && (
<EuiButton
data-test-subj={`aiopsRerunAnalysisButton${shouldRerunAnalysis ? ' shouldRerun' : ''}`}
size="s"
onClick={onRefresh}
color={shouldRerunAnalysis ? 'warning' : 'primary'}
>
<EuiFlexGroup>
<EuiFlexItem>
<FormattedMessage
id="xpack.aiops.rerunAnalysisButtonTitle"
defaultMessage="Run analysis"
/>
</EuiFlexItem>
{shouldRerunAnalysis && (
<>
<EuiFlexItem>
<EuiIconTip
aria-label="Warning"
type="warning"
color="warning"
content={i18n.translate('xpack.aiops.rerunAnalysisTooltipContent', {
defaultMessage:
'Analysis data may be out of date due to selection update. Rerun analysis.',
})}
/>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiButton>
)}
{isRunning && (
<EuiButton data-test-subj="aiopsCancelAnalysisButton" size="s" onClick={onCancel}>
<FormattedMessage id="xpack.aiops.cancelAnalysisButtonTitle" defaultMessage="Cancel" />
</EuiButton>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
{progress === 1 ? (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type="checkInCircleFilled" color={euiTheme.colors.success} />
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="aiopsAnalysisComplete">
<small>
{i18n.translate('xpack.aiops.analysisCompleteLabel', {
defaultMessage: 'Analysis complete',
})}
</small>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
<EuiFlexGroup
direction="column"
gutterSize="none"
css={progress === 1 ? analysisCompleteStyle : undefined}
>
<EuiFlexItem data-test-subj="aiopProgressTitle">
<EuiText size="xs" color="subdued">
<FormattedMessage
@ -72,46 +131,6 @@ export const ProgressControls: FC<ProgressControlProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{!isRunning && (
<EuiButton
data-test-subj={`aiopsRerunAnalysisButton${shouldRerunAnalysis ? ' shouldRerun' : ''}`}
size="s"
onClick={onRefresh}
color={shouldRerunAnalysis ? 'warning' : 'primary'}
fill
>
<EuiFlexGroup>
<EuiFlexItem>
<FormattedMessage
id="xpack.aiops.rerunAnalysisButtonTitle"
defaultMessage="Rerun analysis"
/>
</EuiFlexItem>
{shouldRerunAnalysis && (
<>
<EuiFlexItem>
<EuiIconTip
aria-label="Warning"
type="warning"
color="warning"
content={i18n.translate('xpack.aiops.rerunAnalysisTooltipContent', {
defaultMessage:
'Analysis data may be out of date due to selection update. Rerun analysis.',
})}
/>
</EuiFlexItem>
</>
)}
</EuiFlexGroup>
</EuiButton>
)}
{isRunning && (
<EuiButton data-test-subj="aiopsCancelAnalysisButton" size="s" onClick={onCancel} fill>
<FormattedMessage id="xpack.aiops.cancelAnalysisButtonTitle" defaultMessage="Cancel" />
</EuiButton>
)}
</EuiFlexItem>
{children}
</EuiFlexGroup>
);

View file

@ -56,6 +56,8 @@ interface DocumentCountChartProps {
interval: number;
chartPointsSplitLabel: string;
isBrushCleared: boolean;
/* Timestamp for start of initial analysis */
autoAnalysisStart?: number;
}
const SPEC_ID = 'document_count';
@ -101,6 +103,7 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
interval,
chartPointsSplitLabel,
isBrushCleared,
autoAnalysisStart,
}) => {
const { data, uiSettings, fieldFormats, charts } = useAiopsAppContext();
@ -201,39 +204,6 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
timefilterUpdateHandler({ from, to });
};
const onElementClick: ElementClickListener = ([elementData]) => {
if (brushSelectionUpdateHandler === undefined) {
return;
}
const startRange = (elementData as XYChartElementEvent)[0].x;
const range = {
from: startRange,
to: startRange + interval,
};
if (viewMode === VIEW_MODE.ZOOM) {
timefilterUpdateHandler(range);
} else {
if (
typeof startRange === 'number' &&
originalWindowParameters === undefined &&
windowParameters === undefined &&
adjustedChartPoints !== undefined
) {
const wp = getWindowParameters(
startRange + interval / 2,
timeRangeEarliest,
timeRangeLatest + interval
);
const wpSnap = getSnappedWindowParameters(wp, snapTimestamps);
setOriginalWindowParameters(wpSnap);
setWindowParameters(wpSnap);
brushSelectionUpdateHandler(wpSnap, true);
}
}
};
const timeZone = getTimezone(uiSettings);
const [originalWindowParameters, setOriginalWindowParameters] = useState<
@ -244,6 +214,69 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
WindowParameters | undefined
>();
const triggerAnalysis = useCallback(
(startRange: number) => {
const range = {
from: startRange,
to: startRange + interval,
};
if (viewMode === VIEW_MODE.ZOOM) {
timefilterUpdateHandler(range);
} else {
if (
typeof startRange === 'number' &&
originalWindowParameters === undefined &&
windowParameters === undefined &&
adjustedChartPoints !== undefined
) {
const wp = getWindowParameters(
startRange + interval / 2,
timeRangeEarliest,
timeRangeLatest + interval
);
const wpSnap = getSnappedWindowParameters(wp, snapTimestamps);
setOriginalWindowParameters(wpSnap);
setWindowParameters(wpSnap);
if (brushSelectionUpdateHandler !== undefined) {
brushSelectionUpdateHandler(wpSnap, true);
}
}
}
},
[
interval,
timeRangeEarliest,
timeRangeLatest,
snapTimestamps,
originalWindowParameters,
setWindowParameters,
brushSelectionUpdateHandler,
adjustedChartPoints,
timefilterUpdateHandler,
viewMode,
windowParameters,
]
);
const onElementClick: ElementClickListener = useCallback(
([elementData]) => {
if (brushSelectionUpdateHandler === undefined) {
return;
}
const startRange = (elementData as XYChartElementEvent)[0].x;
triggerAnalysis(startRange);
},
[triggerAnalysis, brushSelectionUpdateHandler]
);
useEffect(() => {
if (autoAnalysisStart !== undefined) {
triggerAnalysis(autoAnalysisStart);
}
}, [triggerAnalysis, autoAnalysisStart]);
useEffect(() => {
if (isBrushCleared && originalWindowParameters !== undefined) {
setOriginalWindowParameters(undefined);
@ -351,7 +384,6 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={(value) => xAxisFormatter.convert(value)}
// temporary fix to reduce horizontal chart margin until fixed in Elastic Charts itself
labelFormat={useLegacyTimeAxis ? undefined : () => ''}
timeAxisLayerCount={useLegacyTimeAxis ? 0 : 2}
style={useLegacyTimeAxis ? {} : MULTILAYER_TIME_AXIS_STYLE}

View file

@ -33,6 +33,7 @@ export interface DocumentCountContentProps {
totalCount: number;
sampleProbability: number;
windowParameters?: WindowParameters;
incomingInitialAnalysisStart?: number;
}
export const DocumentCountContent: FC<DocumentCountContentProps> = ({
@ -44,8 +45,12 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
totalCount,
sampleProbability,
windowParameters,
incomingInitialAnalysisStart,
}) => {
const [isBrushCleared, setIsBrushCleared] = useState(true);
const [initialAnalysisStart, setInitialAnalysisStart] = useState<number | undefined>(
incomingInitialAnalysisStart
);
useEffect(() => {
setIsBrushCleared(windowParameters === undefined);
@ -95,6 +100,7 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
function clearSelection() {
setIsBrushCleared(true);
setInitialAnalysisStart(undefined);
clearSelectionHandler();
}
@ -126,6 +132,7 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
interval={documentCountStats.interval}
chartPointsSplitLabel={documentCountStatsSplitLabel}
isBrushCleared={isBrushCleared}
autoAnalysisStart={initialAnalysisStart}
/>
)}
</>

View file

@ -11,12 +11,13 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
EuiButton,
EuiButtonGroup,
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiText,
} from '@elastic/eui';
@ -44,7 +45,7 @@ import { FieldFilterPopover } from './field_filter_popover';
const groupResultsMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.groupedSwitchLabel.groupResults',
{
defaultMessage: 'Group results',
defaultMessage: 'Smart grouping',
}
);
const groupResultsHelpMessage = i18n.translate(
@ -53,6 +54,20 @@ const groupResultsHelpMessage = i18n.translate(
defaultMessage: 'Items which are unique to a group are marked by an asterisk (*).',
}
);
const groupResultsOffMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.groupedSwitchLabel.groupResultsOff',
{
defaultMessage: 'Off',
}
);
const groupResultsOnMessage = i18n.translate(
'xpack.aiops.spikeAnalysisTable.groupedSwitchLabel.groupResultsOn',
{
defaultMessage: 'On',
}
);
const resultsGroupedOffId = 'aiopsExplainLogRateSpikesGroupingOff';
const resultsGroupedOnId = 'aiopsExplainLogRateSpikesGroupingOn';
/**
* ExplainLogRateSpikes props require a data view.
@ -95,9 +110,11 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
ApiExplainLogRateSpikes['body']['overrides'] | undefined
>(undefined);
const [shouldStart, setShouldStart] = useState(false);
const [toggleIdSelected, setToggleIdSelected] = useState(resultsGroupedOffId);
const onGroupResultsToggle = (e: { target: { checked: React.SetStateAction<boolean> } }) => {
setGroupResults(e.target.checked);
const onGroupResultsToggle = (optionId: string) => {
setToggleIdSelected(optionId);
setGroupResults(optionId === resultsGroupedOnId);
// When toggling the group switch, clear all row selections
clearAllRowState();
@ -174,6 +191,7 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
// Reset grouping to false and clear all row selections when restarting the analysis.
if (resetGroupButton) {
setGroupResults(false);
setToggleIdSelected(resultsGroupedOffId);
clearAllRowState();
}
@ -221,6 +239,19 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
// the toggle wasn't enabled already and no fields were selected to be skipped.
const disabledGroupResultsSwitch = !foundGroups && !groupResults && groupSkipFields.length === 0;
const toggleButtons = [
{
id: resultsGroupedOffId,
label: groupResultsOffMessage,
'data-test-subj': 'aiopsExplainLogRateSpikesGroupSwitchOff',
},
{
id: resultsGroupedOnId,
label: groupResultsOnMessage,
'data-test-subj': 'aiopsExplainLogRateSpikesGroupSwitchOn',
},
];
return (
<div data-test-subj="aiopsExplainLogRateSpikesAnalysis">
<ProgressControls
@ -233,17 +264,24 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
>
<EuiFlexItem grow={false}>
<EuiFormRow display="columnCompressedSwitch">
<EuiSwitch
data-test-subj={`aiopsExplainLogRateSpikesGroupSwitch${
groupResults ? ' checked' : ''
}`}
disabled={disabledGroupResultsSwitch}
showLabel={true}
label={groupResultsMessage}
checked={groupResults}
onChange={onGroupResultsToggle}
compressed
/>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="xs">{groupResultsMessage}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
data-test-subj={`aiopsExplainLogRateSpikesGroupSwitch${
groupResults ? ' checked' : ''
}`}
buttonSize="s"
isDisabled={disabledGroupResultsSwitch}
legend="Smart grouping"
options={toggleButtons}
idSelected={toggleIdSelected}
onChange={onGroupResultsToggle}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, FC } from 'react';
import { EuiEmptyPrompt, EuiHorizontalRule, EuiResizableContainer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Dictionary } from '@kbn/ml-url-state';
import type { WindowParameters } from '@kbn/aiops-utils';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import type { Moment } from 'moment';
import { useData } from '../../../hooks/use_data';
import { DocumentCountContent } from '../../document_count_content/document_count_content';
import { ExplainLogRateSpikesAnalysis } from '../explain_log_rate_spikes_analysis';
import type { GroupTableItem } from '../../spike_analysis_table/types';
import { useSpikeAnalysisTableRowContext } from '../../spike_analysis_table/spike_analysis_table_row_provider';
const DEFAULT_SEARCH_QUERY = { match_all: {} };
export function getDocumentCountStatsSplitLabel(
significantTerm?: SignificantTerm,
group?: GroupTableItem
) {
if (significantTerm) {
return `${significantTerm?.fieldName}:${significantTerm?.fieldValue}`;
} else if (group) {
return i18n.translate('xpack.aiops.spikeAnalysisPage.documentCountStatsSplitGroupLabel', {
defaultMessage: 'Selected group',
});
}
}
export interface ExplainLogRateSpikesContentProps {
/** The data view to analyze. */
dataView: DataView;
setGlobalState?: (params: Dictionary<unknown>) => void;
/** Timestamp for the start of the range for initial analysis */
initialAnalysisStart?: number;
timeRange?: { min: Moment; max: Moment };
/** Elasticsearch query to pass to analysis endpoint */
esSearchQuery?: estypes.QueryDslQueryContainer;
}
export const ExplainLogRateSpikesContent: FC<ExplainLogRateSpikesContentProps> = ({
dataView,
setGlobalState,
initialAnalysisStart,
timeRange,
esSearchQuery = DEFAULT_SEARCH_QUERY,
}) => {
const [windowParameters, setWindowParameters] = useState<WindowParameters | undefined>();
const {
currentSelectedSignificantTerm,
currentSelectedGroup,
setPinnedSignificantTerm,
setPinnedGroup,
setSelectedSignificantTerm,
setSelectedGroup,
} = useSpikeAnalysisTableRowContext();
const { documentStats, earliest, latest } = useData(
dataView,
'explain_log_rage_spikes',
esSearchQuery,
setGlobalState,
currentSelectedSignificantTerm,
currentSelectedGroup,
undefined,
timeRange
);
const { sampleProbability, totalCount, documentCountStats, documentCountStatsCompare } =
documentStats;
function clearSelection() {
setWindowParameters(undefined);
setPinnedSignificantTerm(null);
setPinnedGroup(null);
setSelectedSignificantTerm(null);
setSelectedGroup(null);
}
// Note: Temporarily removed height and disabled sticky histogram until we can fix the scrolling issue in a follow up
return (
<EuiResizableContainer direction="vertical">
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
mode={'collapsible'}
initialSize={40}
minSize={'20%'}
tabIndex={0}
paddingSize="s"
>
{documentCountStats !== undefined && (
<DocumentCountContent
brushSelectionUpdateHandler={setWindowParameters}
clearSelectionHandler={clearSelection}
documentCountStats={documentCountStats}
documentCountStatsSplit={documentCountStatsCompare}
documentCountStatsSplitLabel={getDocumentCountStatsSplitLabel(
currentSelectedSignificantTerm,
currentSelectedGroup
)}
totalCount={totalCount}
sampleProbability={sampleProbability}
windowParameters={windowParameters}
incomingInitialAnalysisStart={initialAnalysisStart}
/>
)}
<EuiHorizontalRule />
</EuiResizablePanel>
{/* <EuiResizableButton /> */}
<EuiResizablePanel
paddingSize="s"
mode={'main'}
initialSize={60}
minSize={'40%'}
tabIndex={0}
>
{earliest !== undefined && latest !== undefined && windowParameters !== undefined && (
<ExplainLogRateSpikesAnalysis
dataView={dataView}
earliest={earliest}
latest={latest}
windowParameters={windowParameters}
searchQuery={esSearchQuery}
sampleProbability={sampleProbability}
/>
)}
{windowParameters === undefined && (
<EuiEmptyPrompt
color="subdued"
hasShadow={false}
hasBorder={false}
css={{ minWidth: '100%' }}
title={
<h2>
<FormattedMessage
id="xpack.aiops.explainLogRateSpikesPage.emptyPromptTitle"
defaultMessage="Click a spike in the histogram chart to start the analysis."
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.explainLogRateSpikesPage.emptyPromptBody"
defaultMessage="The explain log rate spikes feature identifies statistically significant field/value combinations that contribute to a log rate spike."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
)}
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
);
};

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { pick } from 'lodash';
import type { Moment } from 'moment';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
import { StorageContextProvider } from '@kbn/ml-local-storage';
import { UrlStateProvider } from '@kbn/ml-url-state';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { DatePickerContextProvider } from '@kbn/ml-date-picker';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public';
import { AiopsAppContext, type AiopsAppDependencies } from '../../../hooks/use_aiops_app_context';
import { DataSourceContext } from '../../../hooks/use_data_source';
import { AIOPS_STORAGE_KEYS } from '../../../types/storage';
import { SpikeAnalysisTableRowStateProvider } from '../../spike_analysis_table/spike_analysis_table_row_provider';
import { ExplainLogRateSpikesContent } from './explain_log_rate_spikes_content';
const localStorage = new Storage(window.localStorage);
export interface ExplainLogRateSpikesContentWrapperProps {
/** The data view to analyze. */
dataView: DataView;
/** App dependencies */
appDependencies: AiopsAppDependencies;
/** On global timefilter update */
setGlobalState?: any;
/** Timestamp for start of initial analysis */
initialAnalysisStart?: number;
timeRange?: { min: Moment; max: Moment };
/** Elasticsearch query to pass to analysis endpoint */
esSearchQuery?: estypes.QueryDslQueryContainer;
}
export const ExplainLogRateSpikesContentWrapper: FC<ExplainLogRateSpikesContentWrapperProps> = ({
dataView,
appDependencies,
setGlobalState,
initialAnalysisStart,
timeRange,
esSearchQuery,
}) => {
if (!dataView) return null;
if (!dataView.isTimeBased()) {
return (
<EuiCallOut
title={i18n.translate('xpack.aiops.index.dataViewNotBasedOnTimeSeriesNotificationTitle', {
defaultMessage: 'The data view "{dataViewTitle}" is not based on a time series.',
values: { dataViewTitle: dataView.getName() },
})}
color="danger"
iconType="warning"
>
<p>
{i18n.translate('xpack.aiops.index.dataViewNotBasedOnTimeSeriesNotificationDescription', {
defaultMessage: 'Log rate spike analysis only runs over time-based indices.',
})}
</p>
</EuiCallOut>
);
}
const datePickerDeps = {
...pick(appDependencies, ['data', 'http', 'notifications', 'theme', 'uiSettings']),
toMountPoint,
wrapWithTheme,
uiSettingsKeys: UI_SETTINGS,
};
return (
<AiopsAppContext.Provider value={appDependencies}>
<UrlStateProvider>
<DataSourceContext.Provider value={{ dataView, savedSearch: null }}>
<SpikeAnalysisTableRowStateProvider>
<StorageContextProvider storage={localStorage} storageKeys={AIOPS_STORAGE_KEYS}>
<DatePickerContextProvider {...datePickerDeps}>
<ExplainLogRateSpikesContent
dataView={dataView}
setGlobalState={setGlobalState}
initialAnalysisStart={initialAnalysisStart}
timeRange={timeRange}
esSearchQuery={esSearchQuery}
/>
</DatePickerContextProvider>
</StorageContextProvider>
</SpikeAnalysisTableRowStateProvider>
</DataSourceContext.Provider>
</UrlStateProvider>
</AiopsAppContext.Provider>
);
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExplainLogRateSpikesContentWrapper } from './explain_log_rate_spikes_content_wrapper';
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ExplainLogRateSpikesContentWrapper;

View file

@ -8,65 +8,33 @@
import React, { useCallback, useEffect, useState, FC } from 'react';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiPageBody,
EuiPageSection,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPageBody, EuiPageSection, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { WindowParameters } from '@kbn/aiops-utils';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import { Filter, FilterStateStore, Query } from '@kbn/es-query';
import { FormattedMessage } from '@kbn/i18n-react';
import { useUrlState, usePageUrlState } from '@kbn/ml-url-state';
import { useDataSource } from '../../hooks/use_data_source';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { SearchQueryLanguage } from '../../application/utils/search_utils';
import { useData } from '../../hooks/use_data';
import { useSearch } from '../../hooks/use_search';
import {
getDefaultAiOpsListState,
type AiOpsPageUrlState,
} from '../../application/utils/url_state';
import { DocumentCountContent } from '../document_count_content/document_count_content';
import { SearchPanel } from '../search_panel';
import type { GroupTableItem } from '../spike_analysis_table/types';
import { useSpikeAnalysisTableRowContext } from '../spike_analysis_table/spike_analysis_table_row_provider';
import { PageHeader } from '../page_header';
import { ExplainLogRateSpikesAnalysis } from './explain_log_rate_spikes_analysis';
function getDocumentCountStatsSplitLabel(
significantTerm?: SignificantTerm,
group?: GroupTableItem
) {
if (significantTerm) {
return `${significantTerm?.fieldName}:${significantTerm?.fieldValue}`;
} else if (group) {
return i18n.translate('xpack.aiops.spikeAnalysisPage.documentCountStatsSplitGroupLabel', {
defaultMessage: 'Selected group',
});
}
}
import { ExplainLogRateSpikesContent } from './explain_log_rate_spikes_content/explain_log_rate_spikes_content';
export const ExplainLogRateSpikesPage: FC = () => {
const { data: dataService } = useAiopsAppContext();
const { dataView, savedSearch } = useDataSource();
const {
currentSelectedSignificantTerm,
currentSelectedGroup,
setPinnedSignificantTerm,
setPinnedGroup,
setSelectedSignificantTerm,
setSelectedGroup,
} = useSpikeAnalysisTableRowContext();
const { currentSelectedSignificantTerm, currentSelectedGroup } =
useSpikeAnalysisTableRowContext();
const [aiopsListState, setAiopsListState] = usePageUrlState<AiOpsPageUrlState>(
'AIOPS_INDEX_VIEWER',
@ -106,26 +74,20 @@ export const ExplainLogRateSpikesPage: FC = () => {
[selectedSavedSearch, aiopsListState, setAiopsListState]
);
const {
documentStats,
timefilter,
earliest,
latest,
searchQueryLanguage,
searchString,
searchQuery,
} = useData(
{ selectedDataView: dataView, selectedSavedSearch },
const { searchQueryLanguage, searchString, searchQuery } = useSearch(
{ dataView, savedSearch },
aiopsListState
);
const { timefilter } = useData(
dataView,
'explain_log_rage_spikes',
aiopsListState,
searchQuery,
setGlobalState,
currentSelectedSignificantTerm,
currentSelectedGroup
);
const { sampleProbability, totalCount, documentCountStats, documentCountStatsCompare } =
documentStats;
useEffect(
// TODO: Consolidate this hook/function with with Data visualizer's
function clearFiltersOnLeave() {
@ -141,8 +103,6 @@ export const ExplainLogRateSpikesPage: FC = () => {
[dataService.query.filterManager]
);
const [windowParameters, setWindowParameters] = useState<WindowParameters | undefined>();
useEffect(() => {
if (globalState?.time !== undefined) {
timefilter.setTime({
@ -168,14 +128,6 @@ export const ExplainLogRateSpikesPage: FC = () => {
});
}, [dataService, searchQueryLanguage, searchString]);
function clearSelection() {
setWindowParameters(undefined);
setPinnedSignificantTerm(null);
setPinnedGroup(null);
setSelectedSignificantTerm(null);
setSelectedGroup(null);
}
return (
<EuiPageBody data-test-subj="aiopsExplainLogRateSpikesPage" paddingSize="none" panelled={false}>
<PageHeader />
@ -191,61 +143,11 @@ export const ExplainLogRateSpikesPage: FC = () => {
setSearchParams={setSearchParams}
/>
</EuiFlexItem>
{documentCountStats !== undefined && (
<EuiFlexItem>
<EuiPanel paddingSize="m">
<DocumentCountContent
brushSelectionUpdateHandler={setWindowParameters}
clearSelectionHandler={clearSelection}
documentCountStats={documentCountStats}
documentCountStatsSplit={documentCountStatsCompare}
documentCountStatsSplitLabel={getDocumentCountStatsSplitLabel(
currentSelectedSignificantTerm,
currentSelectedGroup
)}
totalCount={totalCount}
sampleProbability={sampleProbability}
windowParameters={windowParameters}
/>
</EuiPanel>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiPanel paddingSize="m">
{earliest !== undefined && latest !== undefined && windowParameters !== undefined && (
<ExplainLogRateSpikesAnalysis
dataView={dataView}
earliest={earliest}
latest={latest}
windowParameters={windowParameters}
searchQuery={searchQuery}
sampleProbability={sampleProbability}
/>
)}
{windowParameters === undefined && (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="xpack.aiops.explainLogRateSpikesPage.emptyPromptTitle"
defaultMessage="Click a spike in the histogram chart to start the analysis."
/>
</h2>
}
titleSize="xs"
body={
<p>
<FormattedMessage
id="xpack.aiops.explainLogRateSpikesPage.emptyPromptBody"
defaultMessage="The explain log rate spikes feature identifies statistically significant field/value combinations that contribute to a log rate spike."
/>
</p>
}
data-test-subj="aiopsNoWindowParametersEmptyPrompt"
/>
)}
</EuiPanel>
</EuiFlexItem>
<ExplainLogRateSpikesContent
dataView={dataView}
setGlobalState={setGlobalState}
esSearchQuery={searchQuery}
/>
</EuiFlexGroup>
</EuiPageSection>
</EuiPageBody>

View file

@ -102,6 +102,7 @@ export const FieldFilterPopover: FC<FieldFilterPopoverProps> = ({
iconType="arrowDown"
iconSide="right"
iconSize="s"
color="text"
>
<FormattedMessage
id="xpack.aiops.explainLogRateSpikesPage.fieldFilterButtonLabel"

View file

@ -22,6 +22,7 @@ import { buildEmptyFilter, Filter } from '@kbn/es-query';
import { usePageUrlState } from '@kbn/ml-url-state';
import { useData } from '../../hooks/use_data';
import { useSearch } from '../../hooks/use_search';
import { useCategorizeRequest } from './use_categorize_request';
import type { EventRate, Category, SparkLinesPerCategory } from './use_categorize_request';
import { CategoryTable } from './category_table';
@ -91,27 +92,22 @@ export const LogCategorizationFlyout: FC<LogCategorizationPageProps> = ({
[cancelRequest, mounted]
);
const {
documentStats,
timefilter,
earliest,
latest,
searchQueryLanguage,
searchString,
searchQuery,
intervalMs,
forceRefresh,
} = useData(
{ selectedDataView: dataView, selectedSavedSearch },
'log_categorization',
const { searchQueryLanguage, searchString, searchQuery } = useSearch(
{ dataView, savedSearch: selectedSavedSearch },
aiopsListState,
undefined,
undefined,
undefined,
BAR_TARGET,
true
);
const { documentStats, timefilter, earliest, latest, intervalMs, forceRefresh } = useData(
dataView,
'log_categorization',
searchQuery,
undefined,
undefined,
undefined,
BAR_TARGET
);
const loadCategories = useCallback(async () => {
const { title: index, timeFieldName: timeField } = dataView;

View file

@ -27,6 +27,7 @@ import { usePageUrlState, useUrlState } from '@kbn/ml-url-state';
import { useDataSource } from '../../hooks/use_data_source';
import { useData } from '../../hooks/use_data';
import { useSearch } from '../../hooks/use_search';
import type { SearchQueryLanguage } from '../../application/utils/search_utils';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import {
@ -110,19 +111,15 @@ export const LogCategorizationPage: FC = () => {
[selectedSavedSearch, aiopsListState, setAiopsListState]
);
const {
documentStats,
timefilter,
earliest,
latest,
searchQueryLanguage,
searchString,
searchQuery,
intervalMs,
} = useData(
{ selectedDataView: dataView, selectedSavedSearch },
const { searchQueryLanguage, searchString, searchQuery } = useSearch(
{ dataView, savedSearch: selectedSavedSearch },
aiopsListState
);
const { documentStats, timefilter, earliest, latest, intervalMs } = useData(
dataView,
'log_categorization',
aiopsListState,
searchQuery,
setGlobalState,
undefined,
undefined,

View file

@ -240,7 +240,11 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', {
defaultMessage: 'Actions',
}),
actions: [viewInDiscoverAction, viewInLogPatternAnalysisAction, copyToClipBoardAction],
actions: [
...(viewInDiscoverAction ? [viewInDiscoverAction] : []),
...(viewInLogPatternAnalysisAction ? [viewInLogPatternAnalysisAction] : []),
copyToClipBoardAction,
],
width: ACTIONS_COLUMN_WIDTH,
valign: 'middle',
},

View file

@ -350,10 +350,14 @@ export const SpikeAnalysisGroupsTable: FC<SpikeAnalysisTableProps> = ({
},
{
'data-test-subj': 'aiOpsSpikeAnalysisTableColumnAction',
name: i18n.translate('xpack.aiops.spikeAnalysisTable.actionsColumnName', {
name: i18n.translate('xpack.aiops.spikeAnalysisGroupsTable.actionsColumnName', {
defaultMessage: 'Actions',
}),
actions: [viewInDiscoverAction, viewInLogPatternAnalysisAction, copyToClipBoardAction],
actions: [
...(viewInDiscoverAction ? [viewInDiscoverAction] : []),
...(viewInLogPatternAnalysisAction ? [viewInLogPatternAnalysisAction] : []),
copyToClipBoardAction,
],
width: ACTIONS_COLUMN_WIDTH,
valign: 'top',
},

View file

@ -7,19 +7,18 @@
import { useEffect, useMemo, useState } from 'react';
import { merge } from 'rxjs';
import type { Moment } from 'moment';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { SignificantTerm } from '@kbn/ml-agg-utils';
import type { SavedSearch } from '@kbn/discover-plugin/public';
import type { Dictionary } from '@kbn/ml-url-state';
import { mlTimefilterRefresh$, useTimefilter } from '@kbn/ml-date-picker';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { PLUGIN_ID } from '../../common';
import type { DocumentStatsSearchStrategyParams } from '../get_document_stats';
import type { AiOpsIndexBasedAppState } from '../application/utils/url_state';
import { getEsQueryFromSavedSearch } from '../application/utils/search_utils';
import type { GroupTableItem } from '../components/spike_analysis_table/types';
import { useTimeBuckets } from './use_time_buckets';
@ -30,25 +29,16 @@ import { useDocumentCountStats } from './use_document_count_stats';
const DEFAULT_BAR_TARGET = 75;
export const useData = (
{
selectedDataView,
selectedSavedSearch,
}: { selectedDataView: DataView; selectedSavedSearch: SavedSearch | null },
selectedDataView: DataView,
contextId: string,
aiopsListState: AiOpsIndexBasedAppState,
searchQuery: estypes.QueryDslQueryContainer,
onUpdate?: (params: Dictionary<unknown>) => void,
selectedSignificantTerm?: SignificantTerm,
selectedGroup?: GroupTableItem | null,
selectedGroup: GroupTableItem | null = null,
barTarget: number = DEFAULT_BAR_TARGET,
readOnly: boolean = false
timeRange?: { min: Moment; max: Moment }
) => {
const {
executionContext,
uiSettings,
data: {
query: { filterManager },
},
} = useAiopsAppContext();
const { executionContext } = useAiopsAppContext();
useExecutionContext(executionContext, {
name: PLUGIN_ID,
@ -58,47 +48,6 @@ export const useData = (
const [lastRefresh, setLastRefresh] = useState(0);
/** Prepare required params to pass to search strategy **/
const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => {
const searchData = getEsQueryFromSavedSearch({
dataView: selectedDataView,
uiSettings,
savedSearch: selectedSavedSearch,
filterManager,
});
if (searchData === undefined || aiopsListState.searchString !== '') {
if (aiopsListState.filters && readOnly === false) {
const globalFilters = filterManager?.getGlobalFilters();
if (filterManager) filterManager.setFilters(aiopsListState.filters);
if (globalFilters) filterManager?.addFilters(globalFilters);
}
return {
searchQuery: aiopsListState.searchQuery,
searchString: aiopsListState.searchString,
searchQueryLanguage: aiopsListState.searchQueryLanguage,
};
} else {
return {
searchQuery: searchData.searchQuery,
searchString: searchData.searchString,
searchQueryLanguage: searchData.queryLanguage,
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedSavedSearch?.id,
selectedDataView.id,
aiopsListState.searchString,
aiopsListState.searchQueryLanguage,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify({
searchQuery: aiopsListState.searchQuery,
}),
lastRefresh,
]);
const _timeBuckets = useTimeBuckets();
const timefilter = useTimefilter({
timeRangeSelector: selectedDataView?.timeFieldName !== undefined,
@ -106,7 +55,7 @@ export const useData = (
});
const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(() => {
const timefilterActiveBounds = timefilter.getActiveBounds();
const timefilterActiveBounds = timeRange ?? timefilter.getActiveBounds();
if (timefilterActiveBounds !== undefined) {
_timeBuckets.setInterval('auto');
_timeBuckets.setBounds(timefilterActiveBounds);
@ -122,7 +71,7 @@ export const useData = (
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastRefresh, searchQuery]);
}, [lastRefresh, searchQuery, timeRange]);
const overallStatsRequest = useMemo(() => {
return fieldStatsRequest
@ -189,9 +138,6 @@ export const useData = (
/** End timestamp filter */
latest: fieldStatsRequest?.latest,
intervalMs: fieldStatsRequest?.intervalMs,
searchQueryLanguage,
searchString,
searchQuery,
forceRefresh: () => setLastRefresh(Date.now()),
};
};

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataView } from '@kbn/data-views-plugin/public';
import type { SavedSearch } from '@kbn/discover-plugin/public';
import { getEsQueryFromSavedSearch } from '../application/utils/search_utils';
import type { AiOpsIndexBasedAppState } from '../application/utils/url_state';
import { useAiopsAppContext } from './use_aiops_app_context';
export const useSearch = (
{ dataView, savedSearch }: { dataView: DataView; savedSearch: SavedSearch | null },
aiopsListState: AiOpsIndexBasedAppState,
readOnly: boolean = false
) => {
const {
uiSettings,
data: {
query: { filterManager },
},
} = useAiopsAppContext();
const searchData = getEsQueryFromSavedSearch({
dataView,
uiSettings,
savedSearch,
filterManager,
});
if (searchData === undefined || (aiopsListState && aiopsListState.searchString !== '')) {
if (aiopsListState?.filters && readOnly === false) {
const globalFilters = filterManager?.getGlobalFilters();
if (filterManager) filterManager.setFilters(aiopsListState.filters);
if (globalFilters) filterManager?.addFilters(globalFilters);
}
return {
searchQuery: aiopsListState?.searchQuery,
searchString: aiopsListState?.searchString,
searchQueryLanguage: aiopsListState?.searchQueryLanguage,
};
} else {
return {
searchQuery: searchData.searchQuery,
searchString: searchData.searchString,
searchQueryLanguage: searchData.queryLanguage,
};
}
};

View file

@ -20,6 +20,7 @@ export type { ChangePointDetectionAppStateProps } from './components/change_poin
export {
ExplainLogRateSpikes,
ExplainLogRateSpikesContent,
LogCategorization,
ChangePointDetection,
} from './shared_lazy_components';

View file

@ -9,6 +9,7 @@ import React, { FC, Suspense } from 'react';
import { EuiErrorBoundary, EuiSkeletonText } from '@elastic/eui';
import type { ExplainLogRateSpikesAppStateProps } from './components/explain_log_rate_spikes';
import type { ExplainLogRateSpikesContentWrapperProps } from './components/explain_log_rate_spikes/explain_log_rate_spikes_content/explain_log_rate_spikes_content_wrapper';
import type { LogCategorizationAppStateProps } from './components/log_categorization';
import type { ChangePointDetectionAppStateProps } from './components/change_point_detection';
@ -16,6 +17,10 @@ const ExplainLogRateSpikesAppStateLazy = React.lazy(
() => import('./components/explain_log_rate_spikes')
);
const ExplainLogRateSpikesContentWrapperLazy = React.lazy(
() => import('./components/explain_log_rate_spikes/explain_log_rate_spikes_content')
);
const LazyWrapper: FC = ({ children }) => (
<EuiErrorBoundary>
<Suspense fallback={<EuiSkeletonText lines={3} />}>{children}</Suspense>
@ -32,6 +37,16 @@ export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesAppStateProps> = (prop
</LazyWrapper>
);
/**
* Lazy-wrapped ExplainLogRateSpikesContentWrapperReact component
* @param {ExplainLogRateSpikesContentWrapperProps} props - properties specifying the data on which to run the analysis.
*/
export const ExplainLogRateSpikesContent: FC<ExplainLogRateSpikesContentWrapperProps> = (props) => (
<LazyWrapper>
<ExplainLogRateSpikesContentWrapperLazy {...props} />
</LazyWrapper>
);
const LogCategorizationAppStateLazy = React.lazy(() => import('./components/log_categorization'));
/**

View file

@ -150,14 +150,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await aiops.explainLogRateSpikesPage.clickRerunAnalysisButton(true);
}
await aiops.explainLogRateSpikesPage.assertProgressTitle('Progress: 100% — Done.');
await aiops.explainLogRateSpikesPage.assertAnalysisComplete();
// The group switch should be disabled by default
await aiops.explainLogRateSpikesPage.assertSpikeAnalysisGroupSwitchExists(false);
if (!isTestDataExpectedWithSampleProbability(testData.expected)) {
// Enabled grouping
await aiops.explainLogRateSpikesPage.clickSpikeAnalysisGroupSwitch(false);
await aiops.explainLogRateSpikesPage.clickSpikeAnalysisGroupSwitchOn();
await aiops.explainLogRateSpikesAnalysisGroupsTable.assertSpikeAnalysisTableExists();

View file

@ -150,15 +150,11 @@ export function ExplainLogRateSpikesPageProvider({
});
},
async clickSpikeAnalysisGroupSwitch(checked: boolean) {
await testSubjects.clickWhenNotDisabledWithoutRetry(
`aiopsExplainLogRateSpikesGroupSwitch${checked ? ' checked' : ''}`
);
async clickSpikeAnalysisGroupSwitchOn() {
await testSubjects.clickWhenNotDisabledWithoutRetry('aiopsExplainLogRateSpikesGroupSwitchOn');
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail(
`aiopsExplainLogRateSpikesGroupSwitch${!checked ? ' checked' : ''}`
);
await testSubjects.existOrFail('aiopsExplainLogRateSpikesGroupSwitch checked');
});
},
@ -246,6 +242,14 @@ export function ExplainLogRateSpikesPageProvider({
});
},
async assertAnalysisComplete() {
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail('aiopsAnalysisComplete');
const currentProgressTitle = await testSubjects.getVisibleText('aiopsAnalysisComplete');
expect(currentProgressTitle).to.be('Analysis complete');
});
},
async navigateToIndexPatternSelection() {
await testSubjects.click('mlMainTab explainLogRateSpikes');
await testSubjects.existOrFail('mlPageSourceSelection');