diff --git a/x-pack/platform/packages/shared/ml/anomaly_utils/types.ts b/x-pack/platform/packages/shared/ml/anomaly_utils/types.ts index 457330fc4d7e0..aadb70eb1a39e 100644 --- a/x-pack/platform/packages/shared/ml/anomaly_utils/types.ts +++ b/x-pack/platform/packages/shared/ml/anomaly_utils/types.ts @@ -454,3 +454,5 @@ export type MlEntityFieldType = 'partition_field' | 'over_field' | 'by_field'; */ export type MlAnomalyResultType = (typeof ML_ANOMALY_RESULT_TYPE)[keyof typeof ML_ANOMALY_RESULT_TYPE]; + +export type AnomalyDateFunction = 'time_of_day' | 'time_of_week'; diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table_columns.js index 383007be5f185..b0c0e9472c16f 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table_columns.js +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomalies_table_columns.js @@ -25,9 +25,9 @@ import { EntityCell } from '../entity_cell'; import { InfluencersCell } from './influencers_cell'; import { LinksMenu } from './links_menu'; import { checkPermission } from '../../capabilities/check_capabilities'; -import { formatValue } from '../../formatters/format_value'; import { INFLUENCERS_LIMIT, ANOMALIES_TABLE_TABS } from './anomalies_table_constants'; import { SeverityCell } from './severity_cell'; +import { AnomalyValueDisplay } from './anomaly_value_display'; function renderTime(date, aggregationInterval) { if (aggregationInterval === 'hour') { @@ -220,7 +220,14 @@ export function getColumns( item.jobId, item.source.detector_index ); - return formatValue(item.actual, item.source.function, fieldFormat, item.source); + return ( + + ); }, sortable: true, className: 'eui-textBreakNormal', @@ -253,7 +260,14 @@ export function getColumns( item.jobId, item.source.detector_index ); - return formatValue(item.typical, item.source.function, fieldFormat, item.source); + return ( + + ); }, sortable: true, className: 'eui-textBreakNormal', diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx index 3853daf36153a..0cbd0bdbf8da4 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_details_utils.tsx @@ -32,6 +32,7 @@ import type { EntityCellFilter } from '../entity_cell'; import { EntityCell } from '../entity_cell'; import { formatValue } from '../../formatters/format_value'; import { useMlKibana } from '../../contexts/kibana'; +import { AnomalyValueDisplay } from './anomaly_value_display'; const TIME_FIELD_NAME = 'timestamp'; @@ -180,7 +181,9 @@ export const DetailsItems: FC<{ title: i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.actualTitle', { defaultMessage: 'Actual', }), - description: formatValue(anomaly.actual, source.function, undefined, source), + description: ( + + ), }); } @@ -189,7 +192,9 @@ export const DetailsItems: FC<{ title: i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.typicalTitle', { defaultMessage: 'Typical', }), - description: formatValue(anomaly.typical, source.function, undefined, source), + description: ( + + ), }); if ( @@ -201,11 +206,12 @@ export const DetailsItems: FC<{ title: i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.upperBoundsTitle', { defaultMessage: 'Upper bound', }), - description: formatValue( - anomaly.source.anomaly_score_explanation?.upper_confidence_bound, - source.function, - undefined, - source + description: ( + ), }); @@ -213,11 +219,12 @@ export const DetailsItems: FC<{ title: i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.lowerBoundsTitle', { defaultMessage: 'Lower bound', }), - description: formatValue( - anomaly.source.anomaly_score_explanation?.lower_confidence_bound, - source.function, - undefined, - source + description: ( + ), }); } diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.test.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.test.tsx new file mode 100644 index 0000000000000..71babcc6dff72 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.test.tsx @@ -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, + 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(); + 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(); + expect(getByTestId('mlAnomalyValue')).toHaveTextContent('1.5,2.5'); + }); + + it('Renders time value with tooltip for time_of_day function', async () => { + const { getByTestId } = render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(getByTestId('mlAnomalyValue')).toHaveTextContent('42.50%'); + }); +}); diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.tsx b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.tsx new file mode 100644 index 0000000000000..2a2e9558d9460 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_display.tsx @@ -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 = ({ + 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 ( + + <> + {timeValueInfo.formattedTime} + {timeValueInfo.dayOffset !== undefined && timeValueInfo.dayOffset !== 0 && ( + + {timeValueInfo.dayOffset > 0 + ? `+${timeValueInfo.dayOffset}` + : timeValueInfo.dayOffset} + + )} + + + ); + } + + // If the function is not a time function, return just the formatted value + return ( + + {formatValue(value, functionName, fieldFormat, record)} + + ); +}; diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_utils.ts b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_utils.ts new file mode 100644 index 0000000000000..2999ee08d0c63 --- /dev/null +++ b/x-pack/platform/plugins/shared/ml/public/application/components/anomalies_table/anomaly_value_utils.ts @@ -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, + }; +} diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/__snapshots__/detector_description_list.test.js.snap b/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/__snapshots__/detector_description_list.test.js.snap index f0f00cc5136b4..e115a3a9b28b4 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/__snapshots__/detector_description_list.test.js.snap +++ b/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/__snapshots__/detector_description_list.test.js.snap @@ -30,8 +30,48 @@ exports[`DetectorDescriptionList render for detector with anomaly values 1`] = ` id="xpack.ml.ruleEditor.detectorDescriptionList.selectedAnomalyDescription" values={ Object { - "actual": 50, - "typical": 1.23, + "actual": , + "typical": , } } />, diff --git a/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/detector_description_list.js b/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/detector_description_list.js index f411e2634b546..0803c4c8f52c9 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/detector_description_list.js +++ b/x-pack/platform/plugins/shared/ml/public/application/components/rule_editor/components/detector_description_list/detector_description_list.js @@ -15,9 +15,8 @@ import React from 'react'; import { EuiDescriptionList } from '@elastic/eui'; -import { formatValue } from '../../../../formatters/format_value'; - import { FormattedMessage } from '@kbn/i18n-react'; +import { AnomalyValueDisplay } from '../../../anomalies_table/anomaly_value_display'; export function DetectorDescriptionList({ job, detector, anomaly }) { const listItems = [ @@ -45,8 +44,6 @@ export function DetectorDescriptionList({ job, detector, anomaly }) { // Format based on magnitude of value at this stage, rather than using the // Kibana field formatter (if set) which would add complexity converting // the entered value to / from e.g. bytes. - const actual = formatValue(anomaly.actual, anomaly.source.function); - const typical = formatValue(anomaly.typical, anomaly.source.function); listItems.push({ title: ( @@ -59,7 +56,22 @@ export function DetectorDescriptionList({ job, detector, anomaly }) { + ), + typical: ( + + ), + }} /> ), }); diff --git a/x-pack/platform/plugins/shared/ml/public/application/formatters/format_value.ts b/x-pack/platform/plugins/shared/ml/public/application/formatters/format_value.ts index 268664c73b31d..a056022d6b0ce 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/formatters/format_value.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/formatters/format_value.ts @@ -14,6 +14,8 @@ import moment from 'moment'; import type { MlAnomalyRecordDoc } from '@kbn/ml-anomaly-utils'; +import type { AnomalyDateFunction } from '@kbn/ml-anomaly-utils/types'; +import type { FieldFormat } from '@kbn/field-formats-plugin/common'; const SIGFIGS_IF_ROUNDING = 3; // Number of sigfigs to use for values < 10 @@ -28,7 +30,7 @@ const SIGFIGS_IF_ROUNDING = 3; // Number of sigfigs to use for values < 10 export function formatValue( value: number[] | number, mlFunction: string, - fieldFormat?: any, + fieldFormat?: FieldFormat, record?: MlAnomalyRecordDoc ) { // actual and typical values in anomaly record results will be arrays. @@ -58,39 +60,15 @@ export function formatValue( export function formatSingleValue( value: number, mlFunction?: string, - fieldFormat?: any, + fieldFormat?: FieldFormat, record?: MlAnomalyRecordDoc ) { if (value === undefined || value === null) { return ''; } - if (mlFunction === 'time_of_week') { - const date = - record !== undefined && record.timestamp !== undefined - ? new Date(record.timestamp) - : new Date(); - /** - * For time_of_week we model "time in UTC" modulo "duration of week in seconds". - * This means the numbers we output from the backend are seconds after a whole number of weeks after 1/1/1970 in UTC. - */ - const remainder = moment(date).unix() % moment.duration(1, 'week').asSeconds(); - const offset = moment.duration(remainder, 'seconds'); - const utcMoment = moment.utc(date).subtract(offset).startOf('day').add(value, 's'); - return moment(utcMoment.valueOf()).format('ddd HH:mm'); - } else if (mlFunction === 'time_of_day') { - /** - * For time_of_day, actual / typical is the UTC offset in seconds from the - * start of the day, so need to manipulate to UTC moment of the start of the day - * that the anomaly occurred using record timestamp if supplied, add on the offset, and finally - * revert to configured timezone for formatting. - */ - const d = - record !== undefined && record.timestamp !== undefined - ? new Date(record.timestamp) - : new Date(); - const utcMoment = moment.utc(d).startOf('day').add(value, 's'); - return moment(utcMoment.valueOf()).format('HH:mm'); + if (mlFunction === 'time_of_week' || mlFunction === 'time_of_day') { + return formatTimeValue(value, mlFunction, record).formatted; } else { if (fieldFormat !== undefined) { return fieldFormat.convert(value, 'text'); @@ -100,11 +78,7 @@ export function formatSingleValue( const absValue = Math.abs(value); if (absValue >= 10000 || absValue === Math.floor(absValue)) { // Output 0 decimal places if whole numbers or >= 10000 - if (fieldFormat !== undefined) { - return fieldFormat.convert(value, 'text'); - } else { - return Number(value.toFixed(0)); - } + return Number(value.toFixed(0)); } else if (absValue >= 10) { // Output to 1 decimal place between 10 and 10000 return Number(value.toFixed(1)); @@ -127,3 +101,58 @@ export function formatSingleValue( } } } + +export function formatTimeValue( + value: number, + mlFunction: AnomalyDateFunction, + record?: MlAnomalyRecordDoc +) { + const date = + record !== undefined && record.timestamp !== undefined + ? new Date(record.timestamp) + : new Date(); + + switch (mlFunction) { + case 'time_of_week': { + /** + * For time_of_week we model "time in UTC" modulo "duration of week in seconds". + * This means the numbers we output from the backend are seconds after a whole number of weeks after 1/1/1970 in UTC. + */ + const remainder = moment(date).unix() % moment.duration(1, 'week').asSeconds(); + const offset = moment.duration(remainder, 'seconds'); + const utcMoment = moment.utc(date).subtract(offset).startOf('day').add(value, 's'); + + // Convert to local timezone for display + const localMoment = moment(utcMoment.valueOf()); + const formatted = localMoment.format('ddd HH:mm'); + + return { formatted, moment: localMoment }; + } + + case 'time_of_day': { + /** + * For time_of_day, actual / typical is the UTC offset in seconds from the + * start of the day, so need to manipulate to UTC moment of the start of the day + * that the anomaly occurred using record timestamp if supplied, add on the offset, and finally + * revert to configured timezone for formatting. + */ + const utcMoment = moment.utc(date).startOf('day').add(value, 's'); + + // Convert to local timezone + const localMoment = moment(utcMoment.valueOf()); + + // Get the reference date in local timezone + const referenceDate = moment(date).startOf('day'); + + // Get the date part of the calculated moment + const localMomentDate = localMoment.clone().startOf('day'); + + // Calculate the day offset + const dayOffset = Math.floor(localMomentDate.diff(referenceDate, 'days')); + + const formatted = localMoment.format('HH:mm'); + + return { formatted, moment: localMoment, dayOffset }; + } + } +} diff --git a/x-pack/platform/plugins/shared/ml/public/application/services/field_format_service.ts b/x-pack/platform/plugins/shared/ml/public/application/services/field_format_service.ts index 2f456ec5713fd..bf8925ed4ebd2 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/services/field_format_service.ts +++ b/x-pack/platform/plugins/shared/ml/public/application/services/field_format_service.ts @@ -5,12 +5,13 @@ * 2.0. */ +import type { FieldFormat } from '@kbn/field-formats-plugin/common'; import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; import type { MlJobService } from './job_service'; import type { MlIndexUtils } from '../util/index_service'; import type { MlApi } from './ml_api_service'; -type FormatsByJobId = Record; +type FormatsByJobId = Record; type IndexPatternIdsByJob = Record; // Service for accessing FieldFormat objects configured for a Kibana data view @@ -71,7 +72,7 @@ export class FieldFormatService { return this.formatsByJob; } catch (error) { console.log('Error populating field formats:', error); // eslint-disable-line no-console - return { formats: {}, error }; + return { formats: {} as FieldFormat[], error }; } } @@ -83,7 +84,7 @@ export class FieldFormatService { } } - async getFormatsForJob(jobId: string): Promise { + async getFormatsForJob(jobId: string): Promise { let jobObj; if (this.mlApi) { const { jobs } = await this.mlApi.getJobs({ jobId }); @@ -92,7 +93,7 @@ export class FieldFormatService { jobObj = this.mlJobService.getJob(jobId); } const detectors = jobObj.analysis_config.detectors || []; - const formatsByDetector: any[] = []; + const formatsByDetector: FieldFormat[] = []; const dataViewId = this.indexPatternIdsByJob[jobId]; if (dataViewId !== undefined) {