Skip to content

Commit

Permalink
[ML] Explain Log Rate Spikes: add popover to analysis table for viewi…
Browse files Browse the repository at this point in the history
…ng other field values (#154689)

## Summary

Related meta issue: #146065

Adds unified field list popover to show top values for fields in the
Explain Log Rate Spikes analysis table.

<img width="1350" alt="image"
src="https://user-images.githubusercontent.com/6446462/231584775-cade3e53-560f-4622-811a-c56747c2e469.png">

Field pairs table:

<img width="1175" alt="image"
src="https://user-images.githubusercontent.com/6446462/231584835-1b82a0bb-eff0-477b-a060-c8b74a067500.png">

<img width="976" alt="image"
src="https://user-images.githubusercontent.com/6446462/231578276-9d99ed13-852b-4744-a9c3-5dd11a35c46d.png">


Expanded row in grouped table: 

<img width="1166" alt="image"
src="https://user-images.githubusercontent.com/6446462/231584916-51271006-94fa-4a25-ac14-e7a99f9728f9.png">

### 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)

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
alvarezmelissa87 and kibanamachine authored Apr 17, 2023
1 parent 5a81817 commit ec0a87c
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 19 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/aiops/kibana.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"charts",
"data",
"lens",
"licensing"
"licensing",
"unifiedFieldList",
],
"requiredBundles": [
"fieldFormats",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* 2.0.
*/

import React, { useEffect, useMemo, useState, type FC } from 'react';
import React, { useEffect, useMemo, useState, FC } from 'react';
import { isEqual, uniq } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';

import {
EuiButton,
Expand All @@ -25,7 +26,6 @@ import { useFetchStream } from '@kbn/aiops-utils';
import type { WindowParameters } from '@kbn/aiops-utils';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { Query } from '@kbn/es-query';

import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { initialState, streamReducer } from '../../../common/api/stream_reducer';
Expand Down Expand Up @@ -67,7 +67,7 @@ interface ExplainLogRateSpikesAnalysisProps {
/** Window parameters for the analysis */
windowParameters: WindowParameters;
/** The search query to be applied to the analysis as a filter */
searchQuery: Query['query'];
searchQuery: estypes.QueryDslQueryContainer;
/** Sample probability to be applied to random sampler aggregations */
sampleProbability: number;
}
Expand Down Expand Up @@ -215,6 +215,7 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
return p + c.groupItemsSortedByUniqueness.length;
}, 0);
const foundGroups = groupTableItems.length > 0 && groupItemCount > 0;
const timeRangeMs = { from: earliest, to: latest };

// Disable the grouping switch toggle only if no groups were found,
// the toggle wasn't enabled already and no fields were selected to be skipped.
Expand Down Expand Up @@ -326,14 +327,18 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
significantTerms={data.significantTerms}
groupTableItems={groupTableItems}
loading={isRunning}
dataViewId={dataView.id}
dataView={dataView}
timeRangeMs={timeRangeMs}
searchQuery={searchQuery}
/>
) : null}
{showSpikeAnalysisTable && !groupResults ? (
<SpikeAnalysisTable
significantTerms={data.significantTerms}
loading={isRunning}
dataViewId={dataView.id}
dataView={dataView}
timeRangeMs={timeRangeMs}
searchQuery={searchQuery}
/>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* 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, useMemo, useState, useCallback } from 'react';
import {
FieldStats,
FieldStatsProps,
FieldStatsServices,
FieldStatsState,
FieldTopValuesBucketParams,
FieldTopValuesBucket,
} from '@kbn/unified-field-list-plugin/public';
import { isDefined } from '@kbn/ml-is-defined';
import type { DataView, DataViewField } from '@kbn/data-plugin/common';
import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker';
import moment from 'moment';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiHorizontalRule, euiPaletteColorBlind, EuiSpacer, EuiText } from '@elastic/eui';

const DEFAULT_COLOR = euiPaletteColorBlind()[0];
const HIGHLIGHTED_FIELD_PROPS = {
color: 'accent',
textProps: {
color: 'accent',
},
};

function getPercentValue(
currentValue: number,
totalCount: number,
digitsRequired: boolean
): number {
const percentageString =
totalCount > 0
? `${(Math.round((currentValue / totalCount) * 1000) / 10).toFixed(digitsRequired ? 1 : 0)}`
: '';
return Number(percentageString);
}

interface FieldStatsContentProps {
dataView: DataView;
field: DataViewField;
fieldName: string;
fieldValue: string | number;
fieldStatsServices: FieldStatsServices;
timeRangeMs?: TimeRangeMs;
dslQuery?: FieldStatsProps['dslQuery'];
}

export const FieldStatsContent: FC<FieldStatsContentProps> = ({
dataView: currentDataView,
field,
fieldName,
fieldValue,
fieldStatsServices,
timeRangeMs,
dslQuery,
}) => {
const [fieldStatsState, setFieldStatsState] = useState<FieldStatsState | undefined>();

// Format timestamp to ISO formatted date strings
const timeRange = useMemo(() => {
// Use the provided timeRange if available
if (timeRangeMs) {
return {
from: moment(timeRangeMs.from).toISOString(),
to: moment(timeRangeMs.to).toISOString(),
};
}

const now = moment();
return { from: now.toISOString(), to: now.toISOString() };
}, [timeRangeMs]);

const onStateChange = useCallback((nextState: FieldStatsState) => {
setFieldStatsState(nextState);
}, []);

const individualStatForDisplay = useMemo((): {
needToDisplayIndividualStat: boolean;
percentage: string;
} => {
const defaultIndividualStatForDisplay = {
needToDisplayIndividualStat: false,
percentage: '< 1%',
};
if (fieldStatsState === undefined) return defaultIndividualStatForDisplay;

const { topValues: currentTopValues, sampledValues } = fieldStatsState;

const idxToHighlight =
currentTopValues?.buckets && Array.isArray(currentTopValues.buckets)
? currentTopValues.buckets.findIndex((value) => value.key === fieldValue)
: null;

const needToDisplayIndividualStat =
idxToHighlight === -1 && fieldName !== undefined && fieldValue !== undefined;

if (needToDisplayIndividualStat) {
defaultIndividualStatForDisplay.needToDisplayIndividualStat = true;

const buckets =
currentTopValues?.buckets && Array.isArray(currentTopValues.buckets)
? currentTopValues.buckets
: [];
let lowestPercentage: number | undefined;

// Taken from the unifiedFieldList plugin
const digitsRequired = buckets.some(
(bucket) => !Number.isInteger(bucket.count / (sampledValues ?? 5000))
);

buckets.forEach((bucket) => {
const currentPercentage = getPercentValue(
bucket.count,
sampledValues ?? 5000,
digitsRequired
);

if (lowestPercentage === undefined || currentPercentage < lowestPercentage) {
lowestPercentage = currentPercentage;
}
});

defaultIndividualStatForDisplay.percentage = `< ${lowestPercentage ?? 1}%`;
}

return defaultIndividualStatForDisplay;
}, [fieldStatsState, fieldName, fieldValue]);

const overrideFieldTopValueBar = useCallback(
(fieldTopValuesBucketParams: FieldTopValuesBucketParams) => {
if (fieldTopValuesBucketParams.type === 'other') {
return { color: 'primary' };
}
return fieldValue === fieldTopValuesBucketParams.fieldValue ? HIGHLIGHTED_FIELD_PROPS : {};
},
[fieldValue]
);

const showFieldStats = timeRange && isDefined(currentDataView) && field;

if (!showFieldStats) return null;
const formatter = currentDataView.getFormatterForField(field);

return (
<>
<FieldStats
key={field.name}
services={fieldStatsServices}
dslQuery={dslQuery ?? { match_all: {} }}
fromDate={timeRange.from}
toDate={timeRange.to}
dataViewOrDataViewId={currentDataView}
field={field}
data-test-subj={`mlAiOpsFieldStatsPopoverContent ${field.name}`}
color={DEFAULT_COLOR}
onStateChange={onStateChange}
overrideFieldTopValueBar={overrideFieldTopValueBar}
/>
{individualStatForDisplay.needToDisplayIndividualStat ? (
<>
<EuiHorizontalRule margin="s" />
<EuiText size="xs" color="subdued">
<FormattedMessage
id="xpack.aiops.fieldContextPopover.notTopTenValueMessage"
defaultMessage="Selected term is not in the top 10"
/>
</EuiText>
<EuiSpacer size="s" />
<FieldTopValuesBucket
field={field}
fieldValue={fieldValue}
formattedPercentage={individualStatForDisplay.percentage}
formattedFieldValue={formatter.convert(fieldValue)}
// Always set as completed since calc is done once we're here
progressValue={100}
count={0}
overrideFieldTopValueBar={overrideFieldTopValueBar}
{...{ 'data-test-subj': 'aiopsNotInTopTenFieldTopValueBucket' }}
{...HIGHLIGHTED_FIELD_PROPS}
/>
</>
) : null}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isDefined } from '@kbn/ml-is-defined';
import {
FieldPopover,
FieldPopoverHeader,
FieldStatsServices,
FieldStatsProps,
} from '@kbn/unified-field-list-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker';
import { useEuiTheme } from '../../hooks/use_eui_theme';
import { FieldStatsContent } from './field_stats_content';

export function FieldStatsPopover({
dataView,
dslQuery,
fieldName,
fieldValue,
fieldStatsServices,
timeRangeMs,
}: {
dataView: DataView;
dslQuery?: FieldStatsProps['dslQuery'];
fieldName: string;
fieldValue: string | number;
fieldStatsServices: FieldStatsServices;
timeRangeMs?: TimeRangeMs;
}) {
const [infoIsOpen, setInfoOpen] = useState(false);
const euiTheme = useEuiTheme();

const closePopover = useCallback(() => setInfoOpen(false), []);

const fieldForStats = useMemo(
() => (isDefined(fieldName) ? dataView.getFieldByName(fieldName) : undefined),
[fieldName, dataView]
);

const trigger = (
<EuiToolTip
content={i18n.translate('xpack.aiops.fieldContextPopover.descriptionTooltipContent', {
defaultMessage: 'Show top field values',
})}
>
<EuiButtonIcon
iconType="inspect"
onClick={(ev: React.MouseEvent<HTMLButtonElement>) => {
setInfoOpen(!infoIsOpen);
}}
aria-label={i18n.translate('xpack.aiops.fieldContextPopover.topFieldValuesAriaLabel', {
defaultMessage: 'Show top field values',
})}
data-test-subj={'aiopsContextPopoverButton'}
style={{ marginLeft: euiTheme.euiSizeXS }}
/>
</EuiToolTip>
);

if (!fieldForStats) return null;

return (
<FieldPopover
isOpen={infoIsOpen}
closePopover={closePopover}
button={trigger}
renderHeader={() => <FieldPopoverHeader field={fieldForStats} closePopover={closePopover} />}
renderContent={() => (
<FieldStatsContent
field={fieldForStats}
fieldName={fieldName}
fieldValue={fieldValue}
dataView={dataView}
fieldStatsServices={fieldStatsServices}
timeRangeMs={timeRangeMs}
dslQuery={dslQuery}
/>
)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* 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.
*/

export { FieldStatsPopover } from './field_stats_popover';
Loading

0 comments on commit ec0a87c

Please sign in to comment.