-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
Changes from all commits
43762e0
98ae785
f4c65cd
ae6e32c
a44cade
a5cc5f3
8387514
d756ecb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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'; | ||
|
@@ -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; | ||
} | ||
|
@@ -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. | ||
|
@@ -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} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we need to pass in the full There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch - removed in a44cade |
||
timeRangeMs={timeRangeMs} | ||
searchQuery={searchQuery} | ||
/> | ||
) : null} | ||
</div> | ||
|
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'; |
There was a problem hiding this comment.
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 thedataViewId
prop and access theid
fromdataView
within the component.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed in a44cade