[ML] Anomalies table: Enhances display for anomaly time function values (#216142)

Introducing tooltips and `+1` / `-1` subscripts for date values to
improve readability for the values of `time_of_day` anomaly detection
function.

Fix for: https://github.com/elastic/kibana/issues/213882
It turns out the formatting was correct for the screenshot provided in
the issue as the upper bound is pointing to the next day.


![image](https://github.com/user-attachments/assets/52ca47d9-ffb2-41dd-b9a6-0442c7fe1a0d)

Format following these changes:

![image](https://github.com/user-attachments/assets/222cc6be-a282-48f8-8391-8d076afb56a8)

![Screenshot 2025-03-27 at 12 01
22](https://github.com/user-attachments/assets/2488df90-717e-4da6-8240-f1002327a007)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Robert Jaszczurek 2025-04-02 09:59:30 +02:00 committed by GitHub
parent e1b172a264
commit e510e533a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 427 additions and 59 deletions

View file

@ -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';

View file

@ -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 (
<AnomalyValueDisplay
value={item.actual}
function={item.source.function}
fieldFormat={fieldFormat}
record={item.source}
/>
);
},
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 (
<AnomalyValueDisplay
value={item.typical}
function={item.source.function}
fieldFormat={fieldFormat}
record={item.source}
/>
);
},
sortable: true,
className: 'eui-textBreakNormal',

View file

@ -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: (
<AnomalyValueDisplay value={anomaly.actual} function={source.function} record={source} />
),
});
}
@ -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: (
<AnomalyValueDisplay value={anomaly.typical} function={source.function} record={source} />
),
});
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: (
<AnomalyValueDisplay
value={anomaly.source.anomaly_score_explanation?.upper_confidence_bound}
function={source.function}
record={source}
/>
),
});
@ -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: (
<AnomalyValueDisplay
value={anomaly.source.anomaly_score_explanation?.lower_confidence_bound}
function={source.function}
record={source}
/>
),
});
}

View file

@ -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(<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%');
});
});

View file

@ -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>
);
};

View file

@ -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,
};
}

View file

@ -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": <AnomalyValueDisplay
function="mean"
record={
Object {
"actual": Array [
50,
],
"source": Object {
"function": "mean",
},
"typical": Array [
1.23,
],
}
}
value={
Array [
50,
]
}
/>,
"typical": <AnomalyValueDisplay
function="mean"
record={
Object {
"actual": Array [
50,
],
"source": Object {
"function": "mean",
},
"typical": Array [
1.23,
],
}
}
value={
Array [
1.23,
]
}
/>,
}
}
/>,

View file

@ -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 }) {
<FormattedMessage
id="xpack.ml.ruleEditor.detectorDescriptionList.selectedAnomalyDescription"
defaultMessage="actual {actual}, typical {typical}"
values={{ actual, typical }}
values={{
actual: (
<AnomalyValueDisplay
value={anomaly.actual}
function={anomaly.source.function}
record={anomaly}
/>
),
typical: (
<AnomalyValueDisplay
value={anomaly.typical}
function={anomaly.source.function}
record={anomaly}
/>
),
}}
/>
),
});

View file

@ -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 };
}
}
}

View file

@ -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<string, any>;
type FormatsByJobId = Record<string, FieldFormat[]>;
type IndexPatternIdsByJob = Record<string, any>;
// 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<any[]> {
async getFormatsForJob(jobId: string): Promise<FieldFormat[]> {
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) {