mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[ML] Anomaly Detection: Annotations enhancements (#70198)
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
65c804efa7
commit
c24f180391
23 changed files with 697 additions and 120 deletions
|
@ -13,3 +13,6 @@ export const ANNOTATION_USER_UNKNOWN = '<user unknown>';
|
|||
|
||||
// UI enforced limit to the maximum number of characters that can be entered for an annotation.
|
||||
export const ANNOTATION_MAX_LENGTH_CHARS = 1000;
|
||||
|
||||
export const ANNOTATION_EVENT_USER = 'user';
|
||||
export const ANNOTATION_EVENT_DELAYED_DATA = 'delayed_data';
|
||||
|
|
|
@ -20,3 +20,5 @@ export enum ANOMALY_THRESHOLD {
|
|||
WARNING = 3,
|
||||
LOW = 0,
|
||||
}
|
||||
|
||||
export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const;
|
||||
|
|
|
@ -58,8 +58,20 @@
|
|||
// ]
|
||||
// }
|
||||
|
||||
import { PartitionFieldsType } from './anomalies';
|
||||
import { ANNOTATION_TYPE } from '../constants/annotations';
|
||||
|
||||
export type AnnotationFieldName = 'partition_field_name' | 'over_field_name' | 'by_field_name';
|
||||
export type AnnotationFieldValue = 'partition_field_value' | 'over_field_value' | 'by_field_value';
|
||||
|
||||
export function getAnnotationFieldName(fieldType: PartitionFieldsType): AnnotationFieldName {
|
||||
return `${fieldType}_name` as AnnotationFieldName;
|
||||
}
|
||||
|
||||
export function getAnnotationFieldValue(fieldType: PartitionFieldsType): AnnotationFieldValue {
|
||||
return `${fieldType}_value` as AnnotationFieldValue;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
_id?: string;
|
||||
create_time?: number;
|
||||
|
@ -73,8 +85,15 @@ export interface Annotation {
|
|||
annotation: string;
|
||||
job_id: string;
|
||||
type: ANNOTATION_TYPE.ANNOTATION | ANNOTATION_TYPE.COMMENT;
|
||||
event?: string;
|
||||
detector_index?: number;
|
||||
partition_field_name?: string;
|
||||
partition_field_value?: string;
|
||||
over_field_name?: string;
|
||||
over_field_value?: string;
|
||||
by_field_name?: string;
|
||||
by_field_value?: string;
|
||||
}
|
||||
|
||||
export function isAnnotation(arg: any): arg is Annotation {
|
||||
return (
|
||||
arg.timestamp !== undefined &&
|
||||
|
@ -93,3 +112,27 @@ export function isAnnotations(arg: any): arg is Annotations {
|
|||
}
|
||||
return arg.every((d: Annotation) => isAnnotation(d));
|
||||
}
|
||||
|
||||
export interface FieldToBucket {
|
||||
field: string;
|
||||
missing?: string | number;
|
||||
}
|
||||
|
||||
export interface FieldToBucketResult {
|
||||
key: string;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
export interface TermAggregationResult {
|
||||
doc_count_error_upper_bound: number;
|
||||
sum_other_doc_count: number;
|
||||
buckets: FieldToBucketResult[];
|
||||
}
|
||||
|
||||
export type EsAggregationResult = Record<string, TermAggregationResult>;
|
||||
|
||||
export interface GetAnnotationsResponse {
|
||||
aggregations?: EsAggregationResult;
|
||||
annotations: Record<string, Annotations>;
|
||||
success: boolean;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PARTITION_FIELDS } from '../constants/anomalies';
|
||||
|
||||
export interface Influencer {
|
||||
influencer_field_name: string;
|
||||
influencer_field_values: string[];
|
||||
|
@ -53,3 +55,5 @@ export interface AnomaliesTableRecord {
|
|||
typicalSort?: any;
|
||||
metricDescriptionSort?: number;
|
||||
}
|
||||
|
||||
export type PartitionFieldsType = typeof PARTITION_FIELDS[number];
|
||||
|
|
|
@ -19,9 +19,10 @@ import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
|
|||
|
||||
interface Props {
|
||||
annotation: Annotation;
|
||||
detectorDescription?: string;
|
||||
}
|
||||
|
||||
export const AnnotationDescriptionList = ({ annotation }: Props) => {
|
||||
export const AnnotationDescriptionList = ({ annotation, detectorDescription }: Props) => {
|
||||
const listItems = [
|
||||
{
|
||||
title: i18n.translate('xpack.ml.timeSeriesExplorer.annotationDescriptionList.jobIdTitle', {
|
||||
|
@ -81,6 +82,33 @@ export const AnnotationDescriptionList = ({ annotation }: Props) => {
|
|||
description: annotation.modified_username,
|
||||
});
|
||||
}
|
||||
if (detectorDescription !== undefined) {
|
||||
listItems.push({
|
||||
title: i18n.translate('xpack.ml.timeSeriesExplorer.annotationDescriptionList.detectorTitle', {
|
||||
defaultMessage: 'Detector',
|
||||
}),
|
||||
description: detectorDescription,
|
||||
});
|
||||
}
|
||||
|
||||
if (annotation.partition_field_name !== undefined) {
|
||||
listItems.push({
|
||||
title: annotation.partition_field_name,
|
||||
description: annotation.partition_field_value,
|
||||
});
|
||||
}
|
||||
if (annotation.over_field_name !== undefined) {
|
||||
listItems.push({
|
||||
title: annotation.over_field_name,
|
||||
description: annotation.over_field_value,
|
||||
});
|
||||
}
|
||||
if (annotation.by_field_name !== undefined) {
|
||||
listItems.push({
|
||||
title: annotation.by_field_name,
|
||||
description: annotation.by_field_value,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiDescriptionList
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import React, { Component, Fragment, FC, ReactNode } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import * as Rx from 'rxjs';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
|
@ -21,13 +22,16 @@ import {
|
|||
EuiSpacer,
|
||||
EuiTextArea,
|
||||
EuiTitle,
|
||||
EuiCheckbox,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { CommonProps } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { ANNOTATION_MAX_LENGTH_CHARS } from '../../../../../common/constants/annotations';
|
||||
import {
|
||||
ANNOTATION_MAX_LENGTH_CHARS,
|
||||
ANNOTATION_EVENT_USER,
|
||||
} from '../../../../../common/constants/annotations';
|
||||
import {
|
||||
annotation$,
|
||||
annotationsRefreshed,
|
||||
|
@ -35,21 +39,45 @@ import {
|
|||
} from '../../../services/annotations_service';
|
||||
import { AnnotationDescriptionList } from '../annotation_description_list';
|
||||
import { DeleteAnnotationModal } from '../delete_annotation_modal';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
import { getToastNotifications } from '../../../util/dependency_cache';
|
||||
import {
|
||||
getAnnotationFieldName,
|
||||
getAnnotationFieldValue,
|
||||
} from '../../../../../common/types/annotations';
|
||||
import { PartitionFieldsType } from '../../../../../common/types/anomalies';
|
||||
import { PARTITION_FIELDS } from '../../../../../common/constants/anomalies';
|
||||
|
||||
interface ViewableDetector {
|
||||
index: number;
|
||||
detector_description: string;
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
fieldName: string;
|
||||
fieldType: string;
|
||||
fieldValue: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
annotation: AnnotationState;
|
||||
chartDetails: {
|
||||
entityData: { entities: Entity[] };
|
||||
functionLabel: string;
|
||||
};
|
||||
detectorIndex: number;
|
||||
detectors: ViewableDetector[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
isDeleteModalVisible: boolean;
|
||||
applyAnnotationToSeries: boolean;
|
||||
}
|
||||
|
||||
class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
||||
public state: State = {
|
||||
isDeleteModalVisible: false,
|
||||
applyAnnotationToSeries: true,
|
||||
};
|
||||
|
||||
public annotationSub: Rx.Subscription | null = null;
|
||||
|
@ -150,11 +178,31 @@ class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
};
|
||||
|
||||
public saveOrUpdateAnnotation = () => {
|
||||
const { annotation } = this.props;
|
||||
|
||||
if (annotation === null) {
|
||||
const { annotation: originalAnnotation, chartDetails, detectorIndex } = this.props;
|
||||
if (originalAnnotation === null) {
|
||||
return;
|
||||
}
|
||||
const annotation = cloneDeep(originalAnnotation);
|
||||
|
||||
if (this.state.applyAnnotationToSeries && chartDetails?.entityData?.entities) {
|
||||
chartDetails.entityData.entities.forEach((entity: Entity) => {
|
||||
const { fieldName, fieldValue } = entity;
|
||||
const fieldType = entity.fieldType as PartitionFieldsType;
|
||||
annotation[getAnnotationFieldName(fieldType)] = fieldName;
|
||||
annotation[getAnnotationFieldValue(fieldType)] = fieldValue;
|
||||
});
|
||||
annotation.detector_index = detectorIndex;
|
||||
}
|
||||
// if unchecked, remove all the partitions before indexing
|
||||
if (!this.state.applyAnnotationToSeries) {
|
||||
delete annotation.detector_index;
|
||||
PARTITION_FIELDS.forEach((fieldType) => {
|
||||
delete annotation[getAnnotationFieldName(fieldType)];
|
||||
delete annotation[getAnnotationFieldValue(fieldType)];
|
||||
});
|
||||
}
|
||||
// Mark the annotation created by `user` if and only if annotation is being created, not updated
|
||||
annotation.event = annotation.event ?? ANNOTATION_EVENT_USER;
|
||||
|
||||
annotation$.next(null);
|
||||
|
||||
|
@ -214,7 +262,7 @@ class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
};
|
||||
|
||||
public render(): ReactNode {
|
||||
const { annotation } = this.props;
|
||||
const { annotation, detectors, detectorIndex } = this.props;
|
||||
const { isDeleteModalVisible } = this.state;
|
||||
|
||||
if (annotation === null) {
|
||||
|
@ -242,10 +290,13 @@ class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
}
|
||||
);
|
||||
}
|
||||
const detector = detectors ? detectors.find((d) => d.index === detectorIndex) : undefined;
|
||||
const detectorDescription =
|
||||
detector && 'detector_description' in detector ? detector.detector_description : '';
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiFlyout onClose={this.cancelEditingHandler} size="s" aria-labelledby="Add annotation">
|
||||
<EuiFlyout onClose={this.cancelEditingHandler} size="m" aria-labelledby="Add annotation">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="mlAnnotationFlyoutTitle">
|
||||
|
@ -264,7 +315,10 @@ class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<AnnotationDescriptionList annotation={annotation} />
|
||||
<AnnotationDescriptionList
|
||||
annotation={annotation}
|
||||
detectorDescription={detectorDescription}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiFormRow
|
||||
label={
|
||||
|
@ -286,6 +340,23 @@ class AnnotationFlyoutUI extends Component<CommonProps & Props> {
|
|||
value={annotation.annotation}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow>
|
||||
<EuiCheckbox
|
||||
id={'xpack.ml.annotationFlyout.applyToPartition'}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationFlyout.applyToPartitionTextLabel"
|
||||
defaultMessage="Apply annotation to this series"
|
||||
/>
|
||||
}
|
||||
checked={this.state.applyAnnotationToSeries}
|
||||
onChange={() =>
|
||||
this.setState({
|
||||
applyAnnotationToSeries: !this.state.applyAnnotationToSeries,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
|
|
|
@ -11,7 +11,7 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
|||
"name": "Annotation",
|
||||
"scope": "row",
|
||||
"sortable": true,
|
||||
"width": "50%",
|
||||
"width": "40%",
|
||||
},
|
||||
Object {
|
||||
"dataType": "date",
|
||||
|
@ -39,6 +39,27 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
|||
"name": "Last modified by",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "event",
|
||||
"name": "Event",
|
||||
"sortable": true,
|
||||
"width": "10%",
|
||||
},
|
||||
Object {
|
||||
"field": "partition_field_value",
|
||||
"name": "Partition",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "over_field_value",
|
||||
"name": "Over",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"field": "by_field_value",
|
||||
"name": "By",
|
||||
"sortable": true,
|
||||
},
|
||||
Object {
|
||||
"actions": Array [
|
||||
Object {
|
||||
|
@ -52,6 +73,12 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
|||
"name": "Actions",
|
||||
"width": "60px",
|
||||
},
|
||||
Object {
|
||||
"dataType": "boolean",
|
||||
"field": "current_series",
|
||||
"name": "current_series",
|
||||
"width": "0px",
|
||||
},
|
||||
]
|
||||
}
|
||||
compressed={true}
|
||||
|
@ -82,6 +109,24 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = `
|
|||
}
|
||||
responsive={true}
|
||||
rowProps={[Function]}
|
||||
search={
|
||||
Object {
|
||||
"box": Object {
|
||||
"incremental": true,
|
||||
"schema": true,
|
||||
},
|
||||
"defaultQuery": "event:(user or delayed_data)",
|
||||
"filters": Array [
|
||||
Object {
|
||||
"field": "event",
|
||||
"multiSelect": "or",
|
||||
"name": "Event",
|
||||
"options": Array [],
|
||||
"type": "field_value_selection",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
sorting={
|
||||
Object {
|
||||
"sort": Object {
|
||||
|
|
|
@ -9,11 +9,9 @@
|
|||
* This version supports both fetching the annotations by itself (used in the jobs list) and
|
||||
* getting the annotations via props (used in Anomaly Explorer and Single Series Viewer).
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import rison from 'rison-node';
|
||||
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import {
|
||||
|
@ -50,7 +48,12 @@ import {
|
|||
annotationsRefresh$,
|
||||
annotationsRefreshed,
|
||||
} from '../../../services/annotations_service';
|
||||
import {
|
||||
ANNOTATION_EVENT_USER,
|
||||
ANNOTATION_EVENT_DELAYED_DATA,
|
||||
} from '../../../../../common/constants/annotations';
|
||||
|
||||
const CURRENT_SERIES = 'current_series';
|
||||
/**
|
||||
* Table component for rendering the lists of annotations for an ML job.
|
||||
*/
|
||||
|
@ -66,7 +69,10 @@ export class AnnotationsTable extends Component {
|
|||
super(props);
|
||||
this.state = {
|
||||
annotations: [],
|
||||
aggregations: null,
|
||||
isLoading: false,
|
||||
queryText: `event:(${ANNOTATION_EVENT_USER} or ${ANNOTATION_EVENT_DELAYED_DATA})`,
|
||||
searchError: undefined,
|
||||
jobId:
|
||||
Array.isArray(this.props.jobs) &&
|
||||
this.props.jobs.length > 0 &&
|
||||
|
@ -74,6 +80,9 @@ export class AnnotationsTable extends Component {
|
|||
? this.props.jobs[0].job_id
|
||||
: undefined,
|
||||
};
|
||||
this.sorting = {
|
||||
sort: { field: 'timestamp', direction: 'asc' },
|
||||
};
|
||||
}
|
||||
|
||||
getAnnotations() {
|
||||
|
@ -92,11 +101,18 @@ export class AnnotationsTable extends Component {
|
|||
earliestMs: null,
|
||||
latestMs: null,
|
||||
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
fields: [
|
||||
{
|
||||
field: 'event',
|
||||
missing: ANNOTATION_EVENT_USER,
|
||||
},
|
||||
],
|
||||
})
|
||||
.toPromise()
|
||||
.then((resp) => {
|
||||
this.setState((prevState, props) => ({
|
||||
annotations: resp.annotations[props.jobs[0].job_id] || [],
|
||||
aggregations: resp.aggregations,
|
||||
errorMessage: undefined,
|
||||
isLoading: false,
|
||||
jobId: props.jobs[0].job_id,
|
||||
|
@ -114,6 +130,25 @@ export class AnnotationsTable extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
getAnnotationsWithExtraInfo(annotations) {
|
||||
// if there is a specific view/chart entities that the annotations can be scoped to
|
||||
// add a new column called 'current_series'
|
||||
if (Array.isArray(this.props.chartDetails?.entityData?.entities)) {
|
||||
return annotations.map((annotation) => {
|
||||
const allMatched = this.props.chartDetails?.entityData?.entities.every(
|
||||
({ fieldType, fieldValue }) => {
|
||||
const field = `${fieldType}_value`;
|
||||
return !(!annotation[field] || annotation[field] !== fieldValue);
|
||||
}
|
||||
);
|
||||
return { ...annotation, [CURRENT_SERIES]: allMatched };
|
||||
});
|
||||
} else {
|
||||
// if not make it return the original annotations
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
|
||||
getJob(jobId) {
|
||||
// check if the job was supplied via props and matches the supplied jobId
|
||||
if (Array.isArray(this.props.jobs) && this.props.jobs.length > 0) {
|
||||
|
@ -134,9 +169,9 @@ export class AnnotationsTable extends Component {
|
|||
Array.isArray(this.props.jobs) &&
|
||||
this.props.jobs.length > 0
|
||||
) {
|
||||
this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() =>
|
||||
this.getAnnotations()
|
||||
);
|
||||
this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => {
|
||||
this.getAnnotations();
|
||||
});
|
||||
annotationsRefreshed();
|
||||
}
|
||||
}
|
||||
|
@ -198,9 +233,11 @@ export class AnnotationsTable extends Component {
|
|||
},
|
||||
},
|
||||
};
|
||||
let mlTimeSeriesExplorer = {};
|
||||
const entityCondition = {};
|
||||
|
||||
if (annotation.timestamp !== undefined && annotation.end_timestamp !== undefined) {
|
||||
appState.mlTimeSeriesExplorer = {
|
||||
mlTimeSeriesExplorer = {
|
||||
zoom: {
|
||||
from: new Date(annotation.timestamp).toISOString(),
|
||||
to: new Date(annotation.end_timestamp).toISOString(),
|
||||
|
@ -216,6 +253,27 @@ export class AnnotationsTable extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
// if the annotation is at the series level
|
||||
// then pass the partitioning field(s) and detector index to the Single Metric Viewer
|
||||
if (_.has(annotation, 'detector_index')) {
|
||||
mlTimeSeriesExplorer.detector_index = annotation.detector_index;
|
||||
}
|
||||
if (_.has(annotation, 'partition_field_value')) {
|
||||
entityCondition[annotation.partition_field_name] = annotation.partition_field_value;
|
||||
}
|
||||
|
||||
if (_.has(annotation, 'over_field_value')) {
|
||||
entityCondition[annotation.over_field_name] = annotation.over_field_value;
|
||||
}
|
||||
|
||||
if (_.has(annotation, 'by_field_value')) {
|
||||
// Note that analyses with by and over fields, will have a top-level by_field_name,
|
||||
// but the by_field_value(s) will be in the nested causes array.
|
||||
entityCondition[annotation.by_field_name] = annotation.by_field_value;
|
||||
}
|
||||
mlTimeSeriesExplorer.entities = entityCondition;
|
||||
appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer;
|
||||
|
||||
const _g = rison.encode(globalSettings);
|
||||
const _a = rison.encode(appState);
|
||||
|
||||
|
@ -251,6 +309,8 @@ export class AnnotationsTable extends Component {
|
|||
render() {
|
||||
const { isSingleMetricViewerLinkVisible = true, isNumberBadgeVisible = false } = this.props;
|
||||
|
||||
const { queryText, searchError } = this.state;
|
||||
|
||||
if (this.props.annotations === undefined) {
|
||||
if (this.state.isLoading === true) {
|
||||
return (
|
||||
|
@ -314,7 +374,7 @@ export class AnnotationsTable extends Component {
|
|||
defaultMessage: 'Annotation',
|
||||
}),
|
||||
sortable: true,
|
||||
width: '50%',
|
||||
width: '40%',
|
||||
scope: 'row',
|
||||
},
|
||||
{
|
||||
|
@ -351,6 +411,14 @@ export class AnnotationsTable extends Component {
|
|||
}),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'event',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.eventColumnName', {
|
||||
defaultMessage: 'Event',
|
||||
}),
|
||||
sortable: true,
|
||||
width: '10%',
|
||||
},
|
||||
];
|
||||
|
||||
const jobIds = _.uniq(annotations.map((a) => a.job_id));
|
||||
|
@ -382,22 +450,23 @@ export class AnnotationsTable extends Component {
|
|||
|
||||
actions.push({
|
||||
render: (annotation) => {
|
||||
// find the original annotation because the table might not show everything
|
||||
const annotationId = annotation._id;
|
||||
const originalAnnotation = annotations.find((d) => d._id === annotationId);
|
||||
const editAnnotationsTooltipText = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.editAnnotationsTooltip"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
);
|
||||
const editAnnotationsTooltipAriaLabelText = (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.editAnnotationsTooltipAriaLabel"
|
||||
defaultMessage="Edit annotation"
|
||||
/>
|
||||
const editAnnotationsTooltipAriaLabelText = i18n.translate(
|
||||
'xpack.ml.annotationsTable.editAnnotationsTooltipAriaLabel',
|
||||
{ defaultMessage: 'Edit annotation' }
|
||||
);
|
||||
return (
|
||||
<EuiToolTip position="bottom" content={editAnnotationsTooltipText}>
|
||||
<EuiButtonIcon
|
||||
onClick={() => annotation$.next(annotation)}
|
||||
onClick={() => annotation$.next(originalAnnotation ?? annotation)}
|
||||
iconType="pencil"
|
||||
aria-label={editAnnotationsTooltipAriaLabelText}
|
||||
/>
|
||||
|
@ -421,17 +490,14 @@ export class AnnotationsTable extends Component {
|
|||
defaultMessage="Job configuration not supported in Single Metric Viewer"
|
||||
/>
|
||||
);
|
||||
const openInSingleMetricViewerAriaLabelText = isDrillDownAvailable ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.openInSingleMetricViewerAriaLabel"
|
||||
defaultMessage="Open in Single Metric Viewer"
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.annotationsTable.jobConfigurationNotSupportedInSingleMetricViewerAriaLabel"
|
||||
defaultMessage="Job configuration not supported in Single Metric Viewer"
|
||||
/>
|
||||
);
|
||||
const openInSingleMetricViewerAriaLabelText = isDrillDownAvailable
|
||||
? i18n.translate('xpack.ml.annotationsTable.openInSingleMetricViewerAriaLabel', {
|
||||
defaultMessage: 'Open in Single Metric Viewer',
|
||||
})
|
||||
: i18n.translate(
|
||||
'xpack.ml.annotationsTable.jobConfigurationNotSupportedInSingleMetricViewerAriaLabel',
|
||||
{ defaultMessage: 'Job configuration not supported in Single Metric Viewer' }
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiToolTip position="bottom" content={openInSingleMetricViewerTooltipText}>
|
||||
|
@ -447,38 +513,152 @@ export class AnnotationsTable extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
columns.push({
|
||||
align: RIGHT_ALIGNMENT,
|
||||
width: '60px',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.actionsColumnName', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
actions,
|
||||
});
|
||||
|
||||
const getRowProps = (item) => {
|
||||
return {
|
||||
onMouseOver: () => this.onMouseOverRow(item),
|
||||
onMouseLeave: () => this.onMouseLeaveRow(),
|
||||
};
|
||||
};
|
||||
let filterOptions = [];
|
||||
const aggregations = this.props.aggregations ?? this.state.aggregations;
|
||||
if (aggregations) {
|
||||
const buckets = aggregations.event.buckets;
|
||||
const foundUser = buckets.findIndex((d) => d.key === ANNOTATION_EVENT_USER) > -1;
|
||||
filterOptions = foundUser
|
||||
? buckets
|
||||
: [{ key: ANNOTATION_EVENT_USER, doc_count: 0 }, ...buckets];
|
||||
}
|
||||
const filters = [
|
||||
{
|
||||
type: 'field_value_selection',
|
||||
field: 'event',
|
||||
name: 'Event',
|
||||
multiSelect: 'or',
|
||||
options: filterOptions.map((field) => ({
|
||||
value: field.key,
|
||||
name: field.key,
|
||||
view: `${field.key} (${field.doc_count})`,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (this.props.detectors) {
|
||||
columns.push({
|
||||
name: i18n.translate('xpack.ml.annotationsTable.detectorColumnName', {
|
||||
defaultMessage: 'Detector',
|
||||
}),
|
||||
width: '10%',
|
||||
render: (item) => {
|
||||
if ('detector_index' in item) {
|
||||
return this.props.detectors[item.detector_index].detector_description;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(this.props.chartDetails?.entityData?.entities)) {
|
||||
// only show the column if the field exists in that job in SMV
|
||||
this.props.chartDetails?.entityData?.entities.forEach((entity) => {
|
||||
if (entity.fieldType === 'partition_field') {
|
||||
columns.push({
|
||||
field: 'partition_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.partitionSMVColumnName', {
|
||||
defaultMessage: 'Partition',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
}
|
||||
if (entity.fieldType === 'over_field') {
|
||||
columns.push({
|
||||
field: 'over_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.overColumnSMVName', {
|
||||
defaultMessage: 'Over',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
}
|
||||
if (entity.fieldType === 'by_field') {
|
||||
columns.push({
|
||||
field: 'by_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.byColumnSMVName', {
|
||||
defaultMessage: 'By',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
filters.push({
|
||||
type: 'is',
|
||||
field: CURRENT_SERIES,
|
||||
name: i18n.translate('xpack.ml.annotationsTable.seriesOnlyFilterName', {
|
||||
defaultMessage: 'Filter to series',
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// else show all the partition columns in AE because there might be multiple jobs
|
||||
columns.push({
|
||||
field: 'partition_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.partitionAEColumnName', {
|
||||
defaultMessage: 'Partition',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
columns.push({
|
||||
field: 'over_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.overAEColumnName', {
|
||||
defaultMessage: 'Over',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'by_field_value',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.byAEColumnName', {
|
||||
defaultMessage: 'By',
|
||||
}),
|
||||
sortable: true,
|
||||
});
|
||||
}
|
||||
const search = {
|
||||
defaultQuery: queryText,
|
||||
box: {
|
||||
incremental: true,
|
||||
schema: true,
|
||||
},
|
||||
filters: filters,
|
||||
};
|
||||
|
||||
columns.push(
|
||||
{
|
||||
align: RIGHT_ALIGNMENT,
|
||||
width: '60px',
|
||||
name: i18n.translate('xpack.ml.annotationsTable.actionsColumnName', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
actions,
|
||||
},
|
||||
{
|
||||
// hidden column, for search only
|
||||
field: CURRENT_SERIES,
|
||||
name: CURRENT_SERIES,
|
||||
dataType: 'boolean',
|
||||
width: '0px',
|
||||
}
|
||||
);
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiInMemoryTable
|
||||
error={searchError}
|
||||
className="eui-textOverflowWrap"
|
||||
compressed={true}
|
||||
items={annotations}
|
||||
items={this.getAnnotationsWithExtraInfo(annotations)}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
pageSizeOptions: [5, 10, 25],
|
||||
}}
|
||||
sorting={{
|
||||
sort: {
|
||||
field: 'timestamp',
|
||||
direction: 'asc',
|
||||
},
|
||||
}}
|
||||
sorting={this.sorting}
|
||||
search={search}
|
||||
rowProps={getRowProps}
|
||||
/>
|
||||
</Fragment>
|
||||
|
|
|
@ -258,7 +258,7 @@ const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService
|
|||
{ influencers, viewBySwimlaneState }
|
||||
): Partial<ExplorerState> => {
|
||||
return {
|
||||
annotationsData,
|
||||
annotations: annotationsData,
|
||||
influencers,
|
||||
loading: false,
|
||||
viewBySwimlaneDataLoading: false,
|
||||
|
|
|
@ -5,11 +5,6 @@
|
|||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
|
||||
import { UrlState } from '../util/url_state';
|
||||
|
||||
import { JobSelection } from '../components/job_selector/use_job_selection';
|
||||
|
||||
import { ExplorerState } from './reducers';
|
||||
import { AppStateSelectedCells } from './explorer_utils';
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import {
|
||||
htmlIdGenerator,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
|
@ -26,6 +27,9 @@ import {
|
|||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiLoadingContent,
|
||||
EuiPanel,
|
||||
EuiAccordion,
|
||||
EuiBadge,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
|
||||
|
@ -138,6 +142,7 @@ export class Explorer extends React.Component {
|
|||
};
|
||||
|
||||
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
|
||||
htmlIdGen = htmlIdGenerator();
|
||||
|
||||
// Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes
|
||||
// and will cause a syntax error when called with getKqlQueryValues
|
||||
|
@ -202,7 +207,7 @@ export class Explorer extends React.Component {
|
|||
const { showCharts, severity } = this.props;
|
||||
|
||||
const {
|
||||
annotationsData,
|
||||
annotations,
|
||||
chartsData,
|
||||
filterActive,
|
||||
filterPlaceHolder,
|
||||
|
@ -216,6 +221,7 @@ export class Explorer extends React.Component {
|
|||
selectedJobs,
|
||||
tableData,
|
||||
} = this.props.explorerState;
|
||||
const { annotationsData, aggregations } = annotations;
|
||||
|
||||
const jobSelectorProps = {
|
||||
dateFormatTz: getDateFormatTz(),
|
||||
|
@ -239,13 +245,12 @@ export class Explorer extends React.Component {
|
|||
</ExplorerPage>
|
||||
);
|
||||
}
|
||||
|
||||
const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10';
|
||||
const mainColumnClasses = `column ${mainColumnWidthClassName}`;
|
||||
|
||||
const timefilter = getTimefilter();
|
||||
const bounds = timefilter.getActiveBounds();
|
||||
|
||||
const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : [];
|
||||
return (
|
||||
<ExplorerPage
|
||||
jobSelectorProps={jobSelectorProps}
|
||||
|
@ -297,29 +302,50 @@ export class Explorer extends React.Component {
|
|||
setSelectedCells={this.props.setSelectedCells}
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
{annotationsData.length > 0 && (
|
||||
<>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.annotationsTitle"
|
||||
defaultMessage="Annotations"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<AnnotationsTable
|
||||
annotations={annotationsData}
|
||||
drillDown={true}
|
||||
numberBadge={false}
|
||||
/>
|
||||
<EuiPanel>
|
||||
<EuiAccordion
|
||||
id={this.htmlIdGen()}
|
||||
buttonContent={
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.annotationsTitle"
|
||||
defaultMessage="Annotations {badge}"
|
||||
values={{
|
||||
badge: (
|
||||
<EuiBadge color={'hollow'}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.annotationsTitleTotalCount"
|
||||
defaultMessage="Total: {count}"
|
||||
values={{ count: annotationsData.length }}
|
||||
/>
|
||||
</EuiBadge>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<AnnotationsTable
|
||||
jobIds={selectedJobIds}
|
||||
annotations={annotationsData}
|
||||
aggregations={aggregations}
|
||||
drillDown={true}
|
||||
numberBadge={false}
|
||||
/>
|
||||
</>
|
||||
</EuiAccordion>
|
||||
</EuiPanel>
|
||||
<AnnotationFlyout />
|
||||
<EuiSpacer size="l" />
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading === false && (
|
||||
<>
|
||||
<EuiPanel>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
|
@ -328,6 +354,7 @@ export class Explorer extends React.Component {
|
|||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
|
@ -360,16 +387,19 @@ export class Explorer extends React.Component {
|
|||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<div className="euiText explorer-charts">
|
||||
{showCharts && <ExplorerChartsContainer {...{ ...chartsData, severity }} />}
|
||||
</div>
|
||||
|
||||
<AnomaliesTable
|
||||
bounds={bounds}
|
||||
tableData={tableData}
|
||||
influencerFilter={this.applyFilter}
|
||||
/>
|
||||
</>
|
||||
</EuiPanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
SWIMLANE_TYPE,
|
||||
VIEW_BY_JOB_LABEL,
|
||||
} from './explorer_constants';
|
||||
import { ANNOTATION_EVENT_USER } from '../../../common/constants/annotations';
|
||||
|
||||
// create new job objects based on standard job config objects
|
||||
// new job objects just contain job id, bucket span in seconds and a selected flag.
|
||||
|
@ -395,6 +396,12 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval,
|
|||
earliestMs: timeRange.earliestMs,
|
||||
latestMs: timeRange.latestMs,
|
||||
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
fields: [
|
||||
{
|
||||
field: 'event',
|
||||
missing: ANNOTATION_EVENT_USER,
|
||||
},
|
||||
],
|
||||
})
|
||||
.toPromise()
|
||||
.then((resp) => {
|
||||
|
@ -410,16 +417,17 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval,
|
|||
}
|
||||
});
|
||||
|
||||
return resolve(
|
||||
annotationsData
|
||||
return resolve({
|
||||
annotationsData: annotationsData
|
||||
.sort((a, b) => {
|
||||
return a.timestamp - b.timestamp;
|
||||
})
|
||||
.map((d, i) => {
|
||||
d.key = String.fromCharCode(65 + i);
|
||||
return d;
|
||||
})
|
||||
);
|
||||
}),
|
||||
aggregations: resp.aggregations,
|
||||
});
|
||||
})
|
||||
.catch((resp) => {
|
||||
console.log('Error loading list of annotations for jobs list:', resp);
|
||||
|
|
|
@ -113,7 +113,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
const { annotationsData, overallState, tableData } = payload;
|
||||
nextState = {
|
||||
...state,
|
||||
annotationsData,
|
||||
annotations: annotationsData,
|
||||
overallSwimlaneData: overallState,
|
||||
tableData,
|
||||
viewBySwimlaneData: {
|
||||
|
|
|
@ -21,10 +21,14 @@ import {
|
|||
SwimlaneData,
|
||||
ViewBySwimLaneData,
|
||||
} from '../../explorer_utils';
|
||||
import { Annotations, EsAggregationResult } from '../../../../../common/types/annotations';
|
||||
import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants';
|
||||
|
||||
export interface ExplorerState {
|
||||
annotationsData: any[];
|
||||
annotations: {
|
||||
annotationsData: Annotations;
|
||||
aggregations: EsAggregationResult;
|
||||
};
|
||||
bounds: TimeRangeBounds | undefined;
|
||||
chartsData: ExplorerChartsData;
|
||||
fieldFormatsLoading: boolean;
|
||||
|
@ -62,7 +66,10 @@ function getDefaultIndexPattern() {
|
|||
|
||||
export function getExplorerDefaultState(): ExplorerState {
|
||||
return {
|
||||
annotationsData: [],
|
||||
annotations: {
|
||||
annotationsData: [],
|
||||
aggregations: {},
|
||||
},
|
||||
bounds: undefined,
|
||||
chartsData: getDefaultChartsData(),
|
||||
fieldFormatsLoading: false,
|
||||
|
|
|
@ -157,7 +157,6 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
|
|||
}, [explorerAppState]);
|
||||
|
||||
const explorerState = useObservable(explorerService.state$);
|
||||
|
||||
const [showCharts] = useShowCharts();
|
||||
const [tableInterval] = useTableInterval();
|
||||
const [tableSeverity] = useTableSeverity();
|
||||
|
|
|
@ -4,7 +4,11 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Annotation } from '../../../../common/types/annotations';
|
||||
import {
|
||||
Annotation,
|
||||
FieldToBucket,
|
||||
GetAnnotationsResponse,
|
||||
} from '../../../../common/types/annotations';
|
||||
import { http, http$ } from '../http_service';
|
||||
import { basePath } from './index';
|
||||
|
||||
|
@ -14,15 +18,19 @@ export const annotations = {
|
|||
earliestMs: number;
|
||||
latestMs: number;
|
||||
maxAnnotations: number;
|
||||
fields: FieldToBucket[];
|
||||
detectorIndex: number;
|
||||
entities: any[];
|
||||
}) {
|
||||
const body = JSON.stringify(obj);
|
||||
return http$<{ annotations: Record<string, Annotation[]> }>({
|
||||
return http$<GetAnnotationsResponse>({
|
||||
path: `${basePath()}/annotations`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
indexAnnotation(obj: any) {
|
||||
|
||||
indexAnnotation(obj: Annotation) {
|
||||
const body = JSON.stringify(obj);
|
||||
return http<any>({
|
||||
path: `${basePath()}/annotations/index`,
|
||||
|
|
|
@ -28,6 +28,8 @@ import {
|
|||
EuiSelect,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiAccordion,
|
||||
EuiBadge,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { getToastNotifications } from '../util/dependency_cache';
|
||||
|
@ -125,6 +127,8 @@ function getTimeseriesexplorerDefaultState() {
|
|||
entitiesLoading: false,
|
||||
entityValues: {},
|
||||
focusAnnotationData: [],
|
||||
focusAggregations: {},
|
||||
focusAggregationInterval: {},
|
||||
focusChartData: undefined,
|
||||
focusForecastData: undefined,
|
||||
fullRefresh: true,
|
||||
|
@ -1025,6 +1029,7 @@ export class TimeSeriesExplorer extends React.Component {
|
|||
entityValues,
|
||||
focusAggregationInterval,
|
||||
focusAnnotationData,
|
||||
focusAggregations,
|
||||
focusChartData,
|
||||
focusForecastData,
|
||||
fullRefresh,
|
||||
|
@ -1075,8 +1080,8 @@ export class TimeSeriesExplorer extends React.Component {
|
|||
const entityControls = this.getControlsForDetector();
|
||||
const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues();
|
||||
const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided();
|
||||
|
||||
const detectorSelectOptions = getViewableDetectors(selectedJob).map((d) => ({
|
||||
const detectors = getViewableDetectors(selectedJob);
|
||||
const detectorSelectOptions = detectors.map((d) => ({
|
||||
value: d.index,
|
||||
text: d.detector_description,
|
||||
}));
|
||||
|
@ -1311,25 +1316,49 @@ export class TimeSeriesExplorer extends React.Component {
|
|||
)}
|
||||
</MlTooltipComponent>
|
||||
</div>
|
||||
{showAnnotations && focusAnnotationData.length > 0 && (
|
||||
<div>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationsTitle"
|
||||
defaultMessage="Annotations"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
{focusAnnotationData && focusAnnotationData.length > 0 && (
|
||||
<EuiAccordion
|
||||
id={'EuiAccordion-blah'}
|
||||
buttonContent={
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.timeSeriesExplorer.annotationsTitle"
|
||||
defaultMessage="Annotations {badge}"
|
||||
values={{
|
||||
badge: (
|
||||
<EuiBadge color={'hollow'}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.annotationsTitleTotalCount"
|
||||
defaultMessage="Total: {count}"
|
||||
values={{ count: focusAnnotationData.length }}
|
||||
/>
|
||||
</EuiBadge>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
}
|
||||
>
|
||||
<AnnotationsTable
|
||||
chartDetails={chartDetails}
|
||||
detectorIndex={selectedDetectorIndex}
|
||||
detectors={detectors}
|
||||
jobIds={[this.props.selectedJobId]}
|
||||
annotations={focusAnnotationData}
|
||||
aggregations={focusAggregations}
|
||||
isSingleMetricViewerLinkVisible={false}
|
||||
isNumberBadgeVisible={true}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
</div>
|
||||
</EuiAccordion>
|
||||
)}
|
||||
<AnnotationFlyout />
|
||||
<AnnotationFlyout
|
||||
chartDetails={chartDetails}
|
||||
detectorIndex={selectedDetectorIndex}
|
||||
detectors={detectors}
|
||||
/>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { mlForecastService } from '../../services/forecast_service';
|
||||
import { mlFunctionToESAggregation } from '../../../../common/util/job_utils';
|
||||
import { Annotation } from '../../../../common/types/annotations';
|
||||
import { ANNOTATION_EVENT_USER } from '../../../../common/constants/annotations';
|
||||
|
||||
export interface Interval {
|
||||
asMilliseconds: () => number;
|
||||
|
@ -37,6 +38,7 @@ export interface FocusData {
|
|||
showForecastCheckbox?: any;
|
||||
focusAnnotationData?: any;
|
||||
focusForecastData?: any;
|
||||
focusAggregations?: any;
|
||||
}
|
||||
|
||||
export function getFocusData(
|
||||
|
@ -84,11 +86,23 @@ export function getFocusData(
|
|||
earliestMs: searchBounds.min.valueOf(),
|
||||
latestMs: searchBounds.max.valueOf(),
|
||||
maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
fields: [
|
||||
{
|
||||
field: 'event',
|
||||
missing: ANNOTATION_EVENT_USER,
|
||||
},
|
||||
],
|
||||
detectorIndex,
|
||||
entities: nonBlankEntities,
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
// silent fail
|
||||
return of({ annotations: {} as Record<string, Annotation[]> });
|
||||
return of({
|
||||
annotations: {} as Record<string, Annotation[]>,
|
||||
aggregations: {},
|
||||
success: false,
|
||||
});
|
||||
})
|
||||
),
|
||||
// Plus query for forecast data if there is a forecastId stored in the appState.
|
||||
|
@ -146,13 +160,14 @@ export function getFocusData(
|
|||
d.key = String.fromCharCode(65 + i);
|
||||
return d;
|
||||
});
|
||||
|
||||
refreshFocusData.focusAggregations = annotations.aggregations;
|
||||
}
|
||||
|
||||
if (forecastData) {
|
||||
refreshFocusData.focusForecastData = processForecastResults(forecastData.results);
|
||||
refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0;
|
||||
}
|
||||
|
||||
return refreshFocusData;
|
||||
})
|
||||
);
|
||||
|
|
|
@ -8,7 +8,8 @@ import Boom from 'boom';
|
|||
import _ from 'lodash';
|
||||
import { ILegacyScopedClusterClient } from 'kibana/server';
|
||||
|
||||
import { ANNOTATION_TYPE } from '../../../common/constants/annotations';
|
||||
import { ANNOTATION_EVENT_USER, ANNOTATION_TYPE } from '../../../common/constants/annotations';
|
||||
import { PARTITION_FIELDS } from '../../../common/constants/anomalies';
|
||||
import {
|
||||
ML_ANNOTATIONS_INDEX_ALIAS_READ,
|
||||
ML_ANNOTATIONS_INDEX_ALIAS_WRITE,
|
||||
|
@ -19,20 +20,35 @@ import {
|
|||
Annotations,
|
||||
isAnnotation,
|
||||
isAnnotations,
|
||||
getAnnotationFieldName,
|
||||
getAnnotationFieldValue,
|
||||
EsAggregationResult,
|
||||
} from '../../../common/types/annotations';
|
||||
|
||||
// TODO All of the following interface/type definitions should
|
||||
// eventually be replaced by the proper upstream definitions
|
||||
interface EsResult {
|
||||
_source: object;
|
||||
_source: Annotation;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export interface FieldToBucket {
|
||||
field: string;
|
||||
missing?: string | number;
|
||||
}
|
||||
|
||||
export interface IndexAnnotationArgs {
|
||||
jobIds: string[];
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
maxAnnotations: number;
|
||||
fields?: FieldToBucket[];
|
||||
detectorIndex?: number;
|
||||
entities?: any[];
|
||||
}
|
||||
|
||||
export interface AggTerm {
|
||||
terms: FieldToBucket;
|
||||
}
|
||||
|
||||
export interface GetParams {
|
||||
|
@ -43,9 +59,8 @@ export interface GetParams {
|
|||
|
||||
export interface GetResponse {
|
||||
success: true;
|
||||
annotations: {
|
||||
[key: string]: Annotations;
|
||||
};
|
||||
annotations: Record<string, Annotations>;
|
||||
aggregations: EsAggregationResult;
|
||||
}
|
||||
|
||||
export interface IndexParams {
|
||||
|
@ -96,10 +111,14 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl
|
|||
earliestMs,
|
||||
latestMs,
|
||||
maxAnnotations,
|
||||
fields,
|
||||
detectorIndex,
|
||||
entities,
|
||||
}: IndexAnnotationArgs) {
|
||||
const obj: GetResponse = {
|
||||
success: true,
|
||||
annotations: {},
|
||||
aggregations: {},
|
||||
};
|
||||
|
||||
const boolCriteria: object[] = [];
|
||||
|
@ -182,6 +201,64 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl
|
|||
});
|
||||
}
|
||||
|
||||
// Find unique buckets (e.g. events) from the queried annotations to show in dropdowns
|
||||
const aggs: Record<string, AggTerm> = {};
|
||||
if (fields) {
|
||||
fields.forEach((fieldToBucket) => {
|
||||
aggs[fieldToBucket.field] = {
|
||||
terms: {
|
||||
...fieldToBucket,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Build should clause to further query for annotations in SMV
|
||||
// we want to show either the exact match with detector index and by/over/partition fields
|
||||
// OR annotations without any partition fields defined
|
||||
let shouldClauses;
|
||||
if (detectorIndex !== undefined && Array.isArray(entities)) {
|
||||
// build clause to get exact match of detector index and by/over/partition fields
|
||||
const beExactMatch = [];
|
||||
beExactMatch.push({
|
||||
term: {
|
||||
detector_index: detectorIndex,
|
||||
},
|
||||
});
|
||||
|
||||
entities.forEach(({ fieldName, fieldType, fieldValue }) => {
|
||||
beExactMatch.push({
|
||||
term: {
|
||||
[getAnnotationFieldName(fieldType)]: fieldName,
|
||||
},
|
||||
});
|
||||
beExactMatch.push({
|
||||
term: {
|
||||
[getAnnotationFieldValue(fieldType)]: fieldValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// clause to get annotations that have no partition fields
|
||||
const haveAnyPartitionFields: object[] = [];
|
||||
PARTITION_FIELDS.forEach((field) => {
|
||||
haveAnyPartitionFields.push({
|
||||
exists: {
|
||||
field: getAnnotationFieldName(field),
|
||||
},
|
||||
});
|
||||
haveAnyPartitionFields.push({
|
||||
exists: {
|
||||
field: getAnnotationFieldValue(field),
|
||||
},
|
||||
});
|
||||
});
|
||||
shouldClauses = [
|
||||
{ bool: { must_not: haveAnyPartitionFields } },
|
||||
{ bool: { must: beExactMatch } },
|
||||
];
|
||||
}
|
||||
|
||||
const params: GetParams = {
|
||||
index: ML_ANNOTATIONS_INDEX_ALIAS_READ,
|
||||
size: maxAnnotations,
|
||||
|
@ -201,8 +278,10 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl
|
|||
},
|
||||
},
|
||||
],
|
||||
...(shouldClauses ? { should: shouldClauses, minimum_should_match: 1 } : {}),
|
||||
},
|
||||
},
|
||||
...(fields ? { aggs } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -217,9 +296,19 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl
|
|||
const docs: Annotations = _.get(resp, ['hits', 'hits'], []).map((d: EsResult) => {
|
||||
// get the original source document and the document id, we need it
|
||||
// to identify the annotation when editing/deleting it.
|
||||
return { ...d._source, _id: d._id } as Annotation;
|
||||
// if original `event` is undefined then substitute with 'user` by default
|
||||
// since annotation was probably generated by user on the UI
|
||||
return {
|
||||
...d._source,
|
||||
event: d._source?.event ?? ANNOTATION_EVENT_USER,
|
||||
_id: d._id,
|
||||
} as Annotation;
|
||||
});
|
||||
|
||||
const aggregations = _.get(resp, ['aggregations'], {}) as EsAggregationResult;
|
||||
if (fields) {
|
||||
obj.aggregations = aggregations;
|
||||
}
|
||||
if (isAnnotations(docs) === false) {
|
||||
// No need to translate, this will not be exposed in the UI.
|
||||
throw new Error(`Annotations didn't pass integrity check.`);
|
||||
|
|
|
@ -6,13 +6,11 @@
|
|||
|
||||
import Boom from 'boom';
|
||||
import { ILegacyScopedClusterClient } from 'kibana/server';
|
||||
import { PARTITION_FIELDS } from '../../../common/constants/anomalies';
|
||||
import { PartitionFieldsType } from '../../../common/types/anomalies';
|
||||
import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns';
|
||||
import { CriteriaField } from './results_service';
|
||||
|
||||
const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const;
|
||||
|
||||
type PartitionFieldsType = typeof PARTITION_FIELDS[number];
|
||||
|
||||
type SearchTerm =
|
||||
| {
|
||||
[key in PartitionFieldsType]?: string;
|
||||
|
|
|
@ -16,6 +16,14 @@ export const indexAnnotationSchema = schema.object({
|
|||
create_username: schema.maybe(schema.string()),
|
||||
modified_time: schema.maybe(schema.number()),
|
||||
modified_username: schema.maybe(schema.string()),
|
||||
event: schema.maybe(schema.string()),
|
||||
detector_index: schema.maybe(schema.number()),
|
||||
partition_field_name: schema.maybe(schema.string()),
|
||||
partition_field_value: schema.maybe(schema.string()),
|
||||
over_field_name: schema.maybe(schema.string()),
|
||||
over_field_value: schema.maybe(schema.string()),
|
||||
by_field_name: schema.maybe(schema.string()),
|
||||
by_field_value: schema.maybe(schema.string()),
|
||||
/** Document id */
|
||||
_id: schema.maybe(schema.string()),
|
||||
key: schema.maybe(schema.string()),
|
||||
|
@ -26,6 +34,25 @@ export const getAnnotationsSchema = schema.object({
|
|||
earliestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]),
|
||||
latestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]),
|
||||
maxAnnotations: schema.number(),
|
||||
/** Fields to find unique values for (e.g. events or created_by) */
|
||||
fields: schema.maybe(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
field: schema.string(),
|
||||
missing: schema.maybe(schema.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
detectorIndex: schema.maybe(schema.number()),
|
||||
entities: schema.maybe(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
fieldType: schema.maybe(schema.string()),
|
||||
fieldName: schema.maybe(schema.string()),
|
||||
fieldValue: schema.maybe(schema.string()),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
export const deleteAnnotationSchema = schema.object({ annotationId: schema.string() });
|
||||
|
|
|
@ -9689,7 +9689,6 @@
|
|||
"xpack.ml.datavisualizerBreadcrumbLabel": "データビジュアライザー",
|
||||
"xpack.ml.dataVisualizerPageLabel": "データビジュアライザー",
|
||||
"xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "メッセージを読み込めませんでした",
|
||||
"xpack.ml.explorer.annotationsTitle": "注釈",
|
||||
"xpack.ml.explorer.anomaliesTitle": "異常",
|
||||
"xpack.ml.explorer.anomalyTimelineTitle": "異常のタイムライン",
|
||||
"xpack.ml.explorer.charts.detectorLabel": "「{fieldName}」で分割された {detectorLabel}{br} Y 軸イベントの分布",
|
||||
|
@ -10802,7 +10801,6 @@
|
|||
"xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "注釈テキストを入力してください",
|
||||
"xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新",
|
||||
"xpack.ml.timeSeriesExplorer.annotationsLabel": "注釈",
|
||||
"xpack.ml.timeSeriesExplorer.annotationsTitle": "注釈",
|
||||
"xpack.ml.timeSeriesExplorer.anomaliesTitle": "異常",
|
||||
"xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": "、初めのジョブを自動選択します",
|
||||
"xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "リクエストされた‘{invalidIdsCount, plural, one {ジョブ} other {件のジョブ}} {invalidIds} をこのダッシュボードで表示できません",
|
||||
|
|
|
@ -9694,7 +9694,6 @@
|
|||
"xpack.ml.datavisualizerBreadcrumbLabel": "数据可视化工具",
|
||||
"xpack.ml.dataVisualizerPageLabel": "数据可视化工具",
|
||||
"xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "无法加载消息",
|
||||
"xpack.ml.explorer.annotationsTitle": "注释",
|
||||
"xpack.ml.explorer.anomaliesTitle": "异常",
|
||||
"xpack.ml.explorer.anomalyTimelineTitle": "异常时间线",
|
||||
"xpack.ml.explorer.charts.detectorLabel": "{detectorLabel}{br}y 轴事件分布按 “{fieldName}” 分割",
|
||||
|
@ -10807,7 +10806,6 @@
|
|||
"xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "输入注释文本",
|
||||
"xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新",
|
||||
"xpack.ml.timeSeriesExplorer.annotationsLabel": "注释",
|
||||
"xpack.ml.timeSeriesExplorer.annotationsTitle": "注释",
|
||||
"xpack.ml.timeSeriesExplorer.anomaliesTitle": "异常",
|
||||
"xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": ",自动选择第一个作业",
|
||||
"xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "您无法在此仪表板中查看请求的 {invalidIdsCount, plural, one {作业} other {作业}} {invalidIds}",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue