Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Explain Log Rate Spikes: adds popover to analysis table for viewing other field values #154689

Merged
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}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we need to pass in the full dataView let's try to get rid of the dataViewId prop and access the id from dataView within the component.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in a44cade

timeRangeMs={timeRangeMs}
searchQuery={searchQuery}
/>
) : null}
{showSpikeAnalysisTable && !groupResults ? (
<SpikeAnalysisTable
significantTerms={data.significantTerms}
loading={isRunning}
dataViewId={dataView.id}
dataView={dataView}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we need to pass in the full dataView let's try to get rid of the dataViewId prop and access the id from dataView within the component.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - removed in a44cade

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