mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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.  Format following these changes:   --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
e1b172a264
commit
e510e533a4
10 changed files with 427 additions and 59 deletions
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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%');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
]
|
||||
}
|
||||
/>,
|
||||
}
|
||||
}
|
||||
/>,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue