[ML] Anomaly Detection: Annotations enhancements (#70198)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen 2020-07-14 12:36:01 -05:00 committed by GitHub
parent 65c804efa7
commit c24f180391
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 697 additions and 120 deletions

View file

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

View file

@ -20,3 +20,5 @@ export enum ANOMALY_THRESHOLD {
WARNING = 3,
LOW = 0,
}
export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const;

View file

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

View file

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

View file

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

View file

@ -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">

View file

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

View file

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

View file

@ -258,7 +258,7 @@ const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService
{ influencers, viewBySwimlaneState }
): Partial<ExplorerState> => {
return {
annotationsData,
annotations: annotationsData,
influencers,
loading: false,
viewBySwimlaneDataLoading: false,

View file

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

View file

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

View file

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

View file

@ -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: {

View file

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

View file

@ -157,7 +157,6 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
}, [explorerAppState]);
const explorerState = useObservable(explorerService.state$);
const [showCharts] = useShowCharts();
const [tableInterval] = useTableInterval();
const [tableSeverity] = useTableSeverity();

View file

@ -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`,

View file

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

View file

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

View file

@ -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.`);

View file

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

View file

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

View file

@ -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} をこのダッシュボードで表示できません",

View file

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