-
Notifications
You must be signed in to change notification settings - Fork 8.5k
[ML] Anomalies table: Enhances display for anomaly time function values #216142
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
Changes from all commits
541ea98
9526415
3df512f
9164a0a
5c5c386
9bd3633
9568d42
1f4434f
75a3563
e393643
7110af1
a0d5f86
b74ab92
32abb7f
1f5b7c6
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 |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| /* | ||
| * 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 from 'react'; | ||
| import { fireEvent, render, screen } from '@testing-library/react'; | ||
| import { AnomalyValueDisplay } from './anomaly_value_display'; | ||
| import { waitForEuiToolTipVisible } from '@elastic/eui/lib/test/rtl'; | ||
| import type { FieldFormat } from '@kbn/field-formats-plugin/common'; | ||
|
|
||
| jest.mock('../../contexts/kibana', () => ({ | ||
| useFieldFormatter: jest.fn().mockReturnValue((value: number | string) => value.toString()), | ||
| })); | ||
|
|
||
| jest.mock('../../formatters/format_value', () => ({ | ||
| formatValue: jest.fn((value, mlFunction, fieldFormat) => { | ||
| if (fieldFormat && fieldFormat.convert) { | ||
| return fieldFormat.convert(value, 'text'); | ||
| } | ||
| return value.toString(); | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock('./anomaly_value_utils', () => ({ | ||
| isTimeFunction: (fn: string) => fn === 'time_of_day' || fn === 'time_of_week', | ||
| useTimeValueInfo: jest.fn((value, functionName) => { | ||
| // Return null for non-time functions | ||
| if (functionName !== 'time_of_day' && functionName !== 'time_of_week') { | ||
| return null; | ||
| } | ||
| // Return time info for time functions | ||
| return { | ||
| formattedTime: '14:30', | ||
| tooltipContent: 'January 1st 14:30', | ||
| dayOffset: value > 86400 ? 1 : 0, | ||
| }; | ||
| }), | ||
| })); | ||
|
|
||
| const baseProps = { | ||
| value: 42.5, | ||
|
Contributor
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. Is it worth setting more decimal places in the test value here to check that the formatting truncates the number of decimal places to the expected value? (Although we are doing this in the
Contributor
Author
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. I think it's not necessary, as |
||
| function: 'mean', | ||
| record: { | ||
| job_id: 'test-job', | ||
| result_type: 'record', | ||
| probability: 0.5, | ||
| record_score: 50, | ||
| initial_record_score: 50, | ||
| bucket_span: 900, | ||
| detector_index: 0, | ||
| is_interim: false, | ||
| timestamp: 1672531200000, | ||
| function: 'mean', | ||
| function_description: 'mean', | ||
| }, | ||
| }; | ||
|
|
||
| describe('AnomalyValueDisplay', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('Renders regular numeric value for non-time functions', () => { | ||
| const { getByTestId } = render(<AnomalyValueDisplay {...baseProps} />); | ||
| expect(getByTestId('mlAnomalyValue')).toHaveTextContent('42.5'); | ||
| }); | ||
|
|
||
| it('Renders array values for non-time functions', () => { | ||
| const props = { | ||
| ...baseProps, | ||
| value: [1.5, 2.5], | ||
| function: 'lat_long', | ||
| }; | ||
|
|
||
| const { getByTestId } = render(<AnomalyValueDisplay {...props} />); | ||
| expect(getByTestId('mlAnomalyValue')).toHaveTextContent('1.5,2.5'); | ||
| }); | ||
|
|
||
| it('Renders time value with tooltip for time_of_day function', async () => { | ||
| const { getByTestId } = render( | ||
| <AnomalyValueDisplay {...baseProps} value={52200} function="time_of_day" /> | ||
| ); | ||
|
|
||
| const element = getByTestId('mlAnomalyTimeValue'); | ||
| expect(element).toBeInTheDocument(); | ||
|
|
||
| fireEvent.mouseOver(element); | ||
| await waitForEuiToolTipVisible(); | ||
|
|
||
| const tooltip = screen.getByTestId('mlAnomalyTimeValueTooltip'); | ||
| expect(tooltip).toHaveTextContent('January 1st 14:30'); | ||
| }); | ||
|
|
||
| it('Renders time value with day offset for time_of_day function', async () => { | ||
| const { getByTestId } = render( | ||
| <AnomalyValueDisplay {...baseProps} value={90000} function="time_of_day" /> | ||
| ); | ||
|
|
||
| const timeText = getByTestId('mlAnomalyTimeValue'); | ||
| const offsetText = getByTestId('mlAnomalyTimeValueOffset'); | ||
|
|
||
| expect(timeText).toBeInTheDocument(); | ||
| expect(offsetText).toBeInTheDocument(); | ||
|
|
||
| fireEvent.mouseOver(timeText); | ||
| await waitForEuiToolTipVisible(); | ||
|
|
||
| const tooltip = screen.getByTestId('mlAnomalyTimeValueTooltip'); | ||
| expect(tooltip).toHaveTextContent('January 1st 14:30'); | ||
| }); | ||
|
|
||
| it('Renders time value with tooltip for time_of_week function', async () => { | ||
| const { getByTestId } = render( | ||
| <AnomalyValueDisplay {...baseProps} value={126000} function="time_of_week" /> | ||
| ); | ||
|
|
||
| const element = getByTestId('mlAnomalyTimeValue'); | ||
| expect(element).toBeInTheDocument(); | ||
|
|
||
| fireEvent.mouseOver(element); | ||
| await waitForEuiToolTipVisible(); | ||
|
|
||
| const tooltip = screen.getByTestId('mlAnomalyTimeValueTooltip'); | ||
| expect(tooltip).toHaveTextContent('January 1st 14:30'); | ||
| }); | ||
|
|
||
| it('Uses first value from array for time functions', () => { | ||
| const { getByTestId, queryByTestId } = render( | ||
| <AnomalyValueDisplay {...baseProps} value={[52200, 54000]} function="time_of_week" /> | ||
| ); | ||
|
|
||
| expect(getByTestId('mlAnomalyTimeValue')).toHaveTextContent('14:30'); | ||
| expect(queryByTestId('mlAnomalyTimeValueOffset')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('Handles custom field format for non-time functions', () => { | ||
| const customFormat = { | ||
| convert: jest.fn().mockReturnValue('42.50%'), | ||
| } as unknown as FieldFormat; | ||
|
|
||
| const { getByTestId } = render( | ||
| <AnomalyValueDisplay {...baseProps} fieldFormat={customFormat} /> | ||
| ); | ||
|
|
||
| expect(getByTestId('mlAnomalyValue')).toHaveTextContent('42.50%'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /* | ||
| * 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 from 'react'; | ||
| import type { MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils/types'; | ||
| import type { FC } from 'react'; | ||
| import { EuiToolTip } from '@elastic/eui'; | ||
| import type { FieldFormat } from '@kbn/field-formats-plugin/common'; | ||
| import { useTimeValueInfo } from './anomaly_value_utils'; | ||
| import { formatValue } from '../../formatters/format_value'; | ||
|
|
||
| interface AnomalyDateValueProps { | ||
| value: number | number[]; | ||
| function: string; | ||
| record?: MlAnomalyRecordDoc; | ||
| fieldFormat?: FieldFormat; | ||
| } | ||
|
|
||
| export const AnomalyValueDisplay: FC<AnomalyDateValueProps> = ({ | ||
| value, | ||
| function: functionName, | ||
| record, | ||
| fieldFormat, | ||
| }) => { | ||
| const singleValue = Array.isArray(value) ? value[0] : value; | ||
| const timeValueInfo = useTimeValueInfo(singleValue, functionName, record); | ||
|
|
||
| // If the function is a time function, return the formatted value and tooltip content | ||
| if (timeValueInfo !== null) { | ||
| return ( | ||
| <EuiToolTip | ||
| content={timeValueInfo.tooltipContent} | ||
| position="left" | ||
| anchorProps={{ | ||
| 'data-test-subj': 'mlAnomalyTimeValue', | ||
| }} | ||
| data-test-subj="mlAnomalyTimeValueTooltip" | ||
| > | ||
| <> | ||
| {timeValueInfo.formattedTime} | ||
| {timeValueInfo.dayOffset !== undefined && timeValueInfo.dayOffset !== 0 && ( | ||
| <sub data-test-subj="mlAnomalyTimeValueOffset"> | ||
| {timeValueInfo.dayOffset > 0 | ||
| ? `+${timeValueInfo.dayOffset}` | ||
| : timeValueInfo.dayOffset} | ||
| </sub> | ||
| )} | ||
| </> | ||
| </EuiToolTip> | ||
| ); | ||
| } | ||
|
|
||
| // If the function is not a time function, return just the formatted value | ||
| return ( | ||
| <span data-test-subj="mlAnomalyValue"> | ||
| {formatValue(value, functionName, fieldFormat, record)} | ||
| </span> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /* | ||
| * 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 { AnomalyDateFunction, MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils/types'; | ||
| import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; | ||
| import { formatTimeValue } from '../../formatters/format_value'; | ||
| import { useFieldFormatter } from '../../contexts/kibana'; | ||
|
|
||
| interface TimeValueInfo { | ||
| formattedTime: string; | ||
| tooltipContent: string; | ||
| dayOffset?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Type guard to check if a function is a time-based function | ||
| */ | ||
| export function isTimeFunction(functionName?: string): functionName is AnomalyDateFunction { | ||
| return functionName === 'time_of_day' || functionName === 'time_of_week'; | ||
| } | ||
|
|
||
| /** | ||
| * Gets formatted time information for time-based functions | ||
| */ | ||
| export function useTimeValueInfo( | ||
| value: number, | ||
| functionName: string, | ||
| record?: MlAnomalyRecordDoc | ||
| ): TimeValueInfo | null { | ||
| const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE); | ||
|
|
||
| if (!isTimeFunction(functionName)) { | ||
| return null; | ||
| } | ||
|
|
||
| const result = formatTimeValue(value, functionName, record); | ||
|
|
||
| // Create a more detailed tooltip format using the moment object | ||
| const tooltipContent = dateFormatter(result.moment.valueOf()); | ||
|
|
||
| return { | ||
| formattedTime: result.formatted, | ||
| tooltipContent, | ||
| dayOffset: result.dayOffset, | ||
| }; | ||
| } |
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.
@valeriy42 out of interest, why do some anomalies not have the upper / lower bounds set?