Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions x-pack/platform/packages/shared/ml/anomaly_utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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} />
),
});
}

Expand All @@ -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 (
Expand All @@ -201,23 +206,25 @@ 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}
Copy link
Contributor

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?

Screenshot 2025-03-27 at 12 52 58

function={source.function}
record={source}
/>
),
});

items.push({
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}
/>
),
});
}
Expand Down
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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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 format_value.ts tests, so maybe not needed here too).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's not necessary, as format_value.ts is tested properly, and here we just want to ensure that the component correctly uses the formatter and displays its output.

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