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