[ML] Embeddable Anomaly Swimlane (#65180)

* [ML] Embeddable Anomaly Swimlane (#64056)

* [ML] embeddables setup

* [ML] fix initialization

* [ML] ts refactoring

* [ML] refactor time_buckets.js

* [ML] async services

* [ML] extract job_selector_flyout.tsx

* [ML] fetch overall swimlane data

* [ML] import explorer styles

* [ML] revert package_globs.ts

* [ML] refactor with container, services with DI

* [ML] resize throttle, fetch based on chart width

* [ML] swimlane embeddable setup

* [ML] explorer service

* [ML] chart_tooltip_service

* [ML] fix types

* [ML] overall type for single job with no influencers

* [ML] improve anomaly_swimlane_initializer ux

* [ML] fix services initialization, unsubscribe on destroy

* [ML] support custom time range

* [ML] add tooltip

* [ML] rollback initGetSwimlaneBucketInterval

* [ML] new tooltip service

* [ML] MlTooltipComponent with render props, fix warning

* [ML] fix typo in the filename

* [ML] remove redundant time range output

* [ML] fix time_buckets.test.js jest tests

* [ML] fix explorer chart tests

* [ML] swimlane tests

* [ML] store job ids instead of complete job objects

* [ML] swimlane limit input

* [ML] memo tooltip component, loading indicator

* [ML] scrollable content

* [ML] support query and filters

* [ML] handle query syntax errors

* [ML] rename anomaly_swimlane_service

* [ML] introduce constants

* [ML] edit panel title during setup

* [ML] withTimeRangeSelector

* [ML] rename explorer_service

* [ML] getJobs$ method with one API call

* [ML] fix groups selection

* [ML] swimlane input resolver hook

* [ML] useSwimlaneInputResolver tests

* [ML] factory test

* [ML] container test

* [ML] set wrapper

* [ML] tooltip tests

* [ML] fix displayScore

* [ML] label colors

* [ML] support edit mode

* [ML] call super render

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
(cherry picked from commit f62df99ae3)

* [ML] fix typing issues
This commit is contained in:
Dima Arnautov 2020-05-05 01:22:43 +02:00 committed by GitHub
parent f9be590d51
commit 76df143d70
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 3103 additions and 946 deletions

View file

@ -13,7 +13,9 @@
"home",
"licensing",
"usageCollection",
"share"
"share",
"embeddable",
"uiActions"
],
"optionalPlugins": [
"security",

View file

@ -4,56 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import React, { useRef, FC } from 'react';
import TooltipTrigger from 'react-popper-tooltip';
import { TooltipValueFormatter } from '@elastic/charts';
import useObservable from 'react-use/lib/useObservable';
import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service';
import './_index.scss';
type RefValue = HTMLElement | null;
function useRefWithCallback(chartTooltipState?: ChartTooltipState) {
const ref = useRef<RefValue>(null);
return (node: RefValue) => {
ref.current = node;
if (
node !== null &&
node.parentElement !== null &&
chartTooltipState !== undefined &&
chartTooltipState.isTooltipVisible
) {
const parentBounding = node.parentElement.getBoundingClientRect();
const { targetPosition, offset } = chartTooltipState;
const contentWidth = document.body.clientWidth - parentBounding.left;
const tooltipWidth = node.clientWidth;
let left = targetPosition.left + offset.x - parentBounding.left;
if (left + tooltipWidth > contentWidth) {
// the tooltip is hanging off the side of the page,
// so move it to the other side of the target
left = left - (tooltipWidth + offset.x);
}
const top = targetPosition.top + offset.y - parentBounding.top;
if (
chartTooltipState.tooltipPosition.left !== left ||
chartTooltipState.tooltipPosition.top !== top
) {
// render the tooltip with adjusted position.
chartTooltip$.next({
...chartTooltipState,
tooltipPosition: { left, top },
});
}
}
};
}
import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types';
import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service';
const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => {
if (!headerData) {
@ -63,48 +22,101 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo
return formatter ? formatter(headerData) : headerData.label;
};
export const ChartTooltip: FC = () => {
const chartTooltipState = useObservable(chartTooltip$);
const chartTooltipElement = useRefWithCallback(chartTooltipState);
const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => {
const [tooltipData, setData] = useState<TooltipData>([]);
const refCallback = useRef<ChildrenArg['triggerRef']>();
if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) {
return <div className="mlChartTooltip mlChartTooltip--hidden" ref={chartTooltipElement} />;
}
useEffect(() => {
const subscription = service.tooltipState$.subscribe(tooltipState => {
if (refCallback.current) {
// update trigger
refCallback.current(tooltipState.target);
}
setData(tooltipState.tooltipData);
});
return () => {
subscription.unsubscribe();
};
}, []);
const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState;
const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`;
const triggerCallback = useCallback(
(({ triggerRef }) => {
// obtain the reference to the trigger setter callback
// to update the target based on changes from the service.
refCallback.current = triggerRef;
// actual trigger is resolved by the service, hence don't render
return null;
}) as TooltipTriggerProps['children'],
[]
);
const tooltipCallback = useCallback(
(({ tooltipRef, getTooltipProps }) => {
return (
<div
{...getTooltipProps({
ref: tooltipRef,
className: 'mlChartTooltip',
})}
>
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">{renderHeader(tooltipData[0])}</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{tooltipData
.slice(1)
.map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
const classes = classNames('mlChartTooltip__item', {
/* eslint @typescript-eslint/camelcase:0 */
echTooltip__rowHighlighted: isHighlighted,
});
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<span className="mlChartTooltip__label">{label}</span>
<span className="mlChartTooltip__value">{value}</span>
</div>
);
})}
</div>
)}
</div>
);
}) as TooltipTriggerProps['tooltip'],
[tooltipData]
);
const isTooltipShown = tooltipData.length > 0;
return (
<div className="mlChartTooltip" style={{ transform }} ref={chartTooltipElement}>
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
<div className="mlChartTooltip__header">
{renderHeader(tooltipData[0], tooltipHeaderFormatter)}
</div>
)}
{tooltipData.length > 1 && (
<div className="mlChartTooltip__list">
{tooltipData
.slice(1)
.map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => {
const classes = classNames('mlChartTooltip__item', {
/* eslint @typescript-eslint/camelcase:0 */
echTooltip__rowHighlighted: isHighlighted,
});
return (
<div
key={`${seriesIdentifier.key}__${valueAccessor}`}
className={classes}
style={{
borderLeftColor: color,
}}
>
<span className="mlChartTooltip__label">{label}</span>
<span className="mlChartTooltip__value">{value}</span>
</div>
);
})}
</div>
)}
</div>
<TooltipTrigger
placement="right-start"
trigger="none"
tooltipShown={isTooltipShown}
tooltip={tooltipCallback}
>
{triggerCallback}
</TooltipTrigger>
);
});
interface MlTooltipComponentProps {
children: (tooltipService: ChartTooltipService) => React.ReactElement;
}
export const MlTooltipComponent: FC<MlTooltipComponentProps> = ({ children }) => {
const service = useMemo(() => new ChartTooltipService(), []);
return (
<>
<Tooltip service={service} />
{children(service)}
</>
);
};

View file

@ -1,42 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject } from 'rxjs';
import { TooltipValue, TooltipValueFormatter } from '@elastic/charts';
export declare const getChartTooltipDefaultState: () => ChartTooltipState;
export interface ChartTooltipValue extends TooltipValue {
skipHeader?: boolean;
}
interface ChartTooltipState {
isTooltipVisible: boolean;
offset: ToolTipOffset;
targetPosition: ClientRect;
tooltipData: ChartTooltipValue[];
tooltipHeaderFormatter?: TooltipValueFormatter;
tooltipPosition: { left: number; top: number };
}
export declare const chartTooltip$: BehaviorSubject<ChartTooltipState>;
interface ToolTipOffset {
x: number;
y: number;
}
interface MlChartTooltipService {
show: (
tooltipData: ChartTooltipValue[],
target?: HTMLElement | null,
offset?: ToolTipOffset
) => void;
hide: () => void;
}
export declare const mlChartTooltipService: MlChartTooltipService;

View file

@ -1,37 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject } from 'rxjs';
export const getChartTooltipDefaultState = () => ({
isTooltipVisible: false,
tooltipData: [],
offset: { x: 0, y: 0 },
targetPosition: { left: 0, top: 0 },
tooltipPosition: { left: 0, top: 0 },
});
export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState());
export const mlChartTooltipService = {
show: (tooltipData, target, offset = { x: 0, y: 0 }) => {
if (typeof target !== 'undefined' && target !== null) {
chartTooltip$.next({
...chartTooltip$.getValue(),
isTooltipVisible: true,
offset,
targetPosition: target.getBoundingClientRect(),
tooltipData,
});
}
},
hide: () => {
chartTooltip$.next({
...getChartTooltipDefaultState(),
isTooltipVisible: false,
});
},
};

View file

@ -4,18 +4,61 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service';
import {
ChartTooltipService,
getChartTooltipDefaultState,
TooltipData,
} from './chart_tooltip_service';
describe('ML - mlChartTooltipService', () => {
it('service API duck typing', () => {
expect(typeof mlChartTooltipService).toBe('object');
expect(typeof mlChartTooltipService.show).toBe('function');
expect(typeof mlChartTooltipService.hide).toBe('function');
describe('ChartTooltipService', () => {
let service: ChartTooltipService;
beforeEach(() => {
service = new ChartTooltipService();
});
it('should fail silently when target is not defined', () => {
expect(() => {
mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null);
}).not.toThrow('Call to show() should fail silently.');
test('should update the tooltip state on show and hide', () => {
const spy = jest.fn();
service.tooltipState$.subscribe(spy);
expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState());
const update = [
{
label: 'new tooltip',
},
] as TooltipData;
const mockEl = document.createElement('div');
service.show(update, mockEl);
expect(spy).toHaveBeenCalledWith({
isTooltipVisible: true,
tooltipData: update,
offset: { x: 0, y: 0 },
target: mockEl,
});
service.hide();
expect(spy).toHaveBeenCalledWith({
isTooltipVisible: false,
tooltipData: ([] as unknown) as TooltipData,
offset: { x: 0, y: 0 },
target: null,
});
});
test('update the tooltip state only on a new value', () => {
const spy = jest.fn();
service.tooltipState$.subscribe(spy);
expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState());
service.hide();
expect(spy).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BehaviorSubject, Observable } from 'rxjs';
import { isEqual } from 'lodash';
import { TooltipValue, TooltipValueFormatter } from '@elastic/charts';
import { distinctUntilChanged } from 'rxjs/operators';
export interface ChartTooltipValue extends TooltipValue {
skipHeader?: boolean;
}
export interface TooltipHeader {
skipHeader: boolean;
}
export type TooltipData = ChartTooltipValue[];
export interface ChartTooltipState {
isTooltipVisible: boolean;
offset: TooltipOffset;
tooltipData: TooltipData;
tooltipHeaderFormatter?: TooltipValueFormatter;
target: HTMLElement | null;
}
interface TooltipOffset {
x: number;
y: number;
}
export const getChartTooltipDefaultState = (): ChartTooltipState => ({
isTooltipVisible: false,
tooltipData: ([] as unknown) as TooltipData,
offset: { x: 0, y: 0 },
target: null,
});
export class ChartTooltipService {
private chartTooltip$ = new BehaviorSubject<ChartTooltipState>(getChartTooltipDefaultState());
public tooltipState$: Observable<ChartTooltipState> = this.chartTooltip$
.asObservable()
.pipe(distinctUntilChanged(isEqual));
public show(
tooltipData: TooltipData,
target: HTMLElement,
offset: TooltipOffset = { x: 0, y: 0 }
) {
if (!target) {
throw new Error('target is required for the tooltip positioning');
}
this.chartTooltip$.next({
...this.chartTooltip$.getValue(),
isTooltipVisible: true,
offset,
tooltipData,
target,
});
}
public hide() {
this.chartTooltip$.next({
...getChartTooltipDefaultState(),
isTooltipVisible: false,
});
}
}

View file

@ -4,5 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { mlChartTooltipService } from './chart_tooltip_service';
export { ChartTooltip } from './chart_tooltip';
export { ChartTooltipService } from './chart_tooltip_service';
export { MlTooltipComponent } from './chart_tooltip';

View file

@ -4,45 +4,23 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import React, { useState, useEffect } from 'react';
import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useMlKibana } from '../../contexts/kibana';
import { Dictionary } from '../../../../common/types/common';
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
import { ml } from '../../services/ml_api_service';
import { useUrlState } from '../../util/url_state';
// @ts-ignore
import { JobSelectorTable } from './job_selector_table/index';
// @ts-ignore
import { IdBadges } from './id_badges/index';
// @ts-ignore
import { NewSelectionIdBadges } from './new_selection_id_badges/index';
import {
getGroupsFromJobs,
getTimeRangeFromSelection,
normalizeTimes,
} from './job_select_service_utils';
import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout';
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
interface GroupObj {
groupId: string;
jobIds: string[];
}
function mergeSelection(
jobIds: string[],
groupObjs: GroupObj[],
@ -71,7 +49,7 @@ function mergeSelection(
}
type GroupsMap = Dictionary<string[]>;
function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
const map: GroupsMap = {};
if (selectedGroups.length) {
@ -83,81 +61,38 @@ function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
return map;
}
const BADGE_LIMIT = 10;
const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels
interface JobSelectorProps {
dateFormatTz: string;
singleSelection: boolean;
timeseriesOnly: boolean;
}
export interface JobSelectionMaps {
jobsMap: Dictionary<MlJobWithTimeRange>;
groupsMap: Dictionary<string[]>;
}
export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) {
const [globalState, setGlobalState] = useUrlState('_g');
const selectedJobIds = globalState?.ml?.jobIds ?? [];
const selectedGroups = globalState?.ml?.groups ?? [];
const [jobs, setJobs] = useState<MlJobWithTimeRange[]>([]);
const [groups, setGroups] = useState<any[]>([]);
const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} });
const [maps, setMaps] = useState<JobSelectionMaps>({
groupsMap: getInitialGroupsMap(selectedGroups),
jobsMap: {},
});
const [selectedIds, setSelectedIds] = useState(
mergeSelection(selectedJobIds, selectedGroups, singleSelection)
);
const [newSelection, setNewSelection] = useState(
mergeSelection(selectedJobIds, selectedGroups, singleSelection)
);
const [showAllBadges, setShowAllBadges] = useState(false);
const [showAllBarBadges, setShowAllBarBadges] = useState(false);
const [applyTimeRange, setApplyTimeRange] = useState(true);
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH);
const flyoutEl = useRef<{ flyout: HTMLElement }>(null);
const {
services: { notifications },
} = useMlKibana();
// Ensure JobSelectionBar gets updated when selection via globalState changes.
useEffect(() => {
setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection));
}, [JSON.stringify([selectedJobIds, selectedGroups])]);
// Ensure current selected ids always show up in flyout
useEffect(() => {
setNewSelection(selectedIds);
}, [isFlyoutVisible]); // eslint-disable-line
// Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below.
// Not wrapping it would cause this dependency to change on every render
const handleResize = useCallback(() => {
if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
// get all cols in flyout table
const tableHeaderCols: NodeListOf<HTMLElement> = flyoutEl.current.flyout.querySelectorAll(
'table thead th'
);
// get the width of the last col
const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16;
const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
setJobs(normalizedJobs);
const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
setGroups(updatedGroups);
setGanttBarWidth(derivedWidth);
}
}, [dateFormatTz, jobs]);
useEffect(() => {
// Ensure ganttBar width gets calculated on resize
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
useEffect(() => {
handleResize();
}, [handleResize, jobs]);
function closeFlyout() {
setIsFlyoutVisible(false);
}
@ -168,78 +103,26 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
function handleJobSelectionClick() {
showFlyout();
ml.jobs
.jobsWithTimerange(dateFormatTz)
.then(resp => {
const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
setJobs(normalizedJobs);
setGroups(groupsWithTimerange);
setMaps({ groupsMap, jobsMap: resp.jobsMap });
})
.catch((err: any) => {
console.error('Error fetching jobs with time range', err); // eslint-disable-line
const { toasts } = notifications;
toasts.addDanger({
title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', {
defaultMessage: 'An error occurred fetching jobs. Refresh and try again.',
}),
});
});
}
function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) {
setNewSelection(selectionFromTable);
}
function applySelection() {
// allNewSelection will be a list of all job ids (including those from groups) selected from the table
const allNewSelection: string[] = [];
const groupSelection: Array<{ groupId: string; jobIds: string[] }> = [];
newSelection.forEach(id => {
if (maps.groupsMap[id] !== undefined) {
// Push all jobs from selected groups into the newSelection list
allNewSelection.push(...maps.groupsMap[id]);
// if it's a group - push group obj to set in global state
groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] });
} else {
allNewSelection.push(id);
}
});
// create a Set to remove duplicate values
const allNewSelectionUnique = Array.from(new Set(allNewSelection));
const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({
newSelection,
jobIds,
groups: newGroups,
time,
}) => {
setSelectedIds(newSelection);
setNewSelection([]);
closeFlyout();
const time = applyTimeRange
? getTimeRangeFromSelection(jobs, allNewSelectionUnique)
: undefined;
setGlobalState({
ml: {
jobIds: allNewSelectionUnique,
groups: groupSelection,
jobIds,
groups: newGroups,
},
...(time !== undefined ? { time } : {}),
});
}
function toggleTimerangeSwitch() {
setApplyTimeRange(!applyTimeRange);
}
function removeId(id: string) {
setNewSelection(newSelection.filter(item => item !== id));
}
function clearSelection() {
setNewSelection([]);
}
closeFlyout();
};
function renderJobSelectionBar() {
return (
@ -280,103 +163,16 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
function renderFlyout() {
if (isFlyoutVisible) {
return (
<EuiFlyout
// @ts-ignore
ref={flyoutEl}
onClose={closeFlyout}
aria-labelledby="jobSelectorFlyout"
size="l"
data-test-subj="mlFlyoutJobSelector"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">
{i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
defaultMessage: 'Job selection',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody className="mlJobSelectorFlyoutBody">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
<NewSelectionIdBadges
limit={BADGE_LIMIT}
maps={maps}
newSelection={newSelection}
onDeleteClick={removeId}
onLinkClick={() => setShowAllBadges(!showAllBadges)}
showAllBadges={showAllBadges}
/>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
{!singleSelection && newSelection.length > 0 && (
<EuiButtonEmpty
onClick={clearSelection}
size="xs"
data-test-subj="mlFlyoutJobSelectorButtonClearSelection"
>
{i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
defaultMessage: 'Clear all',
})}
</EuiButtonEmpty>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.ml.jobSelector.applyTimerangeSwitchLabel', {
defaultMessage: 'Apply timerange',
})}
checked={applyTimeRange}
onChange={toggleTimerangeSwitch}
data-test-subj="mlFlyoutJobSelectorSwitchApplyTimeRange"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<JobSelectorTable
jobs={jobs}
ganttBarWidth={ganttBarWidth}
groupsList={groups}
onSelection={handleNewSelection}
selectedIds={newSelection}
singleSelection={singleSelection}
timeseriesOnly={timeseriesOnly}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={applySelection}
fill
isDisabled={newSelection.length === 0}
data-test-subj="mlFlyoutJobSelectorButtonApply"
>
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={closeFlyout}
data-test-subj="mlFlyoutJobSelectorButtonClose"
>
{i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
<JobSelectorFlyout
dateFormatTz={dateFormatTz}
timeseriesOnly={timeseriesOnly}
singleSelection={singleSelection}
selectedIds={selectedIds}
onSelectionConfirmed={applySelection}
onJobsFetched={setMaps}
onFlyoutClose={closeFlyout}
maps={maps}
/>
);
}
}
@ -388,9 +184,3 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
</div>
);
}
JobSelector.propTypes = {
selectedJobIds: PropTypes.array,
singleSelection: PropTypes.bool,
timeseriesOnly: PropTypes.bool,
};

View file

@ -4,18 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { PropTypes } from 'prop-types';
import { EuiBadge } from '@elastic/eui';
import { tabColor } from '../../../../../common/util/group_color_utils';
import React, { FC } from 'react';
import { EuiBadge, EuiBadgeProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { tabColor } from '../../../../../common/util/group_color_utils';
export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) {
interface JobSelectorBadgeProps {
icon?: boolean;
id: string;
isGroup?: boolean;
numJobs?: number;
removeId?: Function;
}
export const JobSelectorBadge: FC<JobSelectorBadgeProps> = ({
icon,
id,
isGroup = false,
numJobs,
removeId,
}) => {
const color = isGroup ? tabColor(id) : 'hollow';
let props = { color };
let props = { color } as EuiBadgeProps;
let jobCount;
if (icon === true) {
if (icon === true && removeId) {
// @ts-ignore
props = {
...props,
iconType: 'cross',
@ -37,11 +51,4 @@ export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId
{`${id}${jobCount ? jobCount : ''}`}
</EuiBadge>
);
}
JobSelectorBadge.propTypes = {
icon: PropTypes.bool,
id: PropTypes.string.isRequired,
isGroup: PropTypes.bool,
numJobs: PropTypes.number,
removeId: PropTypes.func,
};

View file

@ -0,0 +1,289 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { NewSelectionIdBadges } from './new_selection_id_badges';
// @ts-ignore
import { JobSelectorTable } from './job_selector_table';
import {
getGroupsFromJobs,
getTimeRangeFromSelection,
normalizeTimes,
} from './job_select_service_utils';
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
import { ml } from '../../services/ml_api_service';
import { useMlKibana } from '../../contexts/kibana';
import { JobSelectionMaps } from './job_selector';
export const BADGE_LIMIT = 10;
export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels
export interface JobSelectorFlyoutProps {
dateFormatTz: string;
selectedIds?: string[];
newSelection?: string[];
onFlyoutClose: () => void;
onJobsFetched?: (maps: JobSelectionMaps) => void;
onSelectionChange?: (newSelection: string[]) => void;
onSelectionConfirmed: (payload: {
newSelection: string[];
jobIds: string[];
groups: Array<{ groupId: string; jobIds: string[] }>;
time: any;
}) => void;
singleSelection: boolean;
timeseriesOnly: boolean;
maps: JobSelectionMaps;
withTimeRangeSelector?: boolean;
}
export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
dateFormatTz,
selectedIds = [],
singleSelection,
timeseriesOnly,
onJobsFetched,
onSelectionChange,
onSelectionConfirmed,
onFlyoutClose,
maps,
withTimeRangeSelector = true,
}) => {
const {
services: { notifications },
} = useMlKibana();
const [newSelection, setNewSelection] = useState(selectedIds);
const [showAllBadges, setShowAllBadges] = useState(false);
const [applyTimeRange, setApplyTimeRange] = useState(true);
const [jobs, setJobs] = useState<MlJobWithTimeRange[]>([]);
const [groups, setGroups] = useState<any[]>([]);
const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH);
const [jobGroupsMaps, setJobGroupsMaps] = useState(maps);
const flyoutEl = useRef<{ flyout: HTMLElement }>(null);
function applySelection() {
// allNewSelection will be a list of all job ids (including those from groups) selected from the table
const allNewSelection: string[] = [];
const groupSelection: Array<{ groupId: string; jobIds: string[] }> = [];
newSelection.forEach(id => {
if (jobGroupsMaps.groupsMap[id] !== undefined) {
// Push all jobs from selected groups into the newSelection list
allNewSelection.push(...jobGroupsMaps.groupsMap[id]);
// if it's a group - push group obj to set in global state
groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] });
} else {
allNewSelection.push(id);
}
});
// create a Set to remove duplicate values
const allNewSelectionUnique = Array.from(new Set(allNewSelection));
const time = applyTimeRange
? getTimeRangeFromSelection(jobs, allNewSelectionUnique)
: undefined;
onSelectionConfirmed({
newSelection: allNewSelectionUnique,
jobIds: allNewSelectionUnique,
groups: groupSelection,
time,
});
}
function removeId(id: string) {
setNewSelection(newSelection.filter(item => item !== id));
}
function toggleTimerangeSwitch() {
setApplyTimeRange(!applyTimeRange);
}
function clearSelection() {
setNewSelection([]);
}
function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) {
setNewSelection(selectionFromTable);
}
// Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below.
// Not wrapping it would cause this dependency to change on every render
const handleResize = useCallback(() => {
if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
// get all cols in flyout table
const tableHeaderCols: NodeListOf<HTMLElement> = flyoutEl.current.flyout.querySelectorAll(
'table thead th'
);
// get the width of the last col
const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16;
const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
setJobs(normalizedJobs);
const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
setGroups(updatedGroups);
setGanttBarWidth(derivedWidth);
}
}, [dateFormatTz, jobs]);
// Fetch jobs list on flyout open
useEffect(() => {
fetchJobs();
}, []);
async function fetchJobs() {
try {
const resp = await ml.jobs.jobsWithTimerange(dateFormatTz);
const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
setJobs(normalizedJobs);
setGroups(groupsWithTimerange);
setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap });
if (onJobsFetched) {
onJobsFetched({ groupsMap, jobsMap: resp.jobsMap });
}
} catch (e) {
console.error('Error fetching jobs with time range', e); // eslint-disable-line
const { toasts } = notifications;
toasts.addDanger({
title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', {
defaultMessage: 'An error occurred fetching jobs. Refresh and try again.',
}),
});
}
}
useEffect(() => {
// Ensure ganttBar width gets calculated on resize
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
useEffect(() => {
handleResize();
}, [handleResize, jobs]);
return (
<EuiFlyout
// @ts-ignore
ref={flyoutEl}
onClose={onFlyoutClose}
aria-labelledby="jobSelectorFlyout"
size="l"
data-test-subj="mlFlyoutJobSelector"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="flyoutTitle">
{i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
defaultMessage: 'Job selection',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody className="mlJobSelectorFlyoutBody">
<EuiFlexGroup direction="column" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
<NewSelectionIdBadges
limit={BADGE_LIMIT}
maps={jobGroupsMaps}
newSelection={newSelection}
onDeleteClick={removeId}
onLinkClick={() => setShowAllBadges(!showAllBadges)}
showAllBadges={showAllBadges}
/>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
{!singleSelection && newSelection.length > 0 && (
<EuiButtonEmpty
onClick={clearSelection}
size="xs"
data-test-subj="mlFlyoutJobSelectorButtonClearSelection"
>
{i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
defaultMessage: 'Clear all',
})}
</EuiButtonEmpty>
)}
</EuiFlexItem>
{withTimeRangeSelector && (
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.ml.jobSelector.applyTimerangeSwitchLabel', {
defaultMessage: 'Apply timerange',
})}
checked={applyTimeRange}
onChange={toggleTimerangeSwitch}
data-test-subj="mlFlyoutJobSelectorSwitchApplyTimeRange"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<JobSelectorTable
jobs={jobs}
ganttBarWidth={ganttBarWidth}
groupsList={groups}
onSelection={handleNewSelection}
selectedIds={newSelection}
singleSelection={singleSelection}
timeseriesOnly={timeseriesOnly}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={applySelection}
fill
isDisabled={newSelection.length === 0}
data-test-subj="mlFlyoutJobSelectorButtonApply"
>
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={onFlyoutClose}
data-test-subj="mlFlyoutJobSelectorButtonClose"
>
{i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -224,7 +224,7 @@ export function JobSelectorTable({
<Fragment>
{jobs.length === 0 && <EuiLoadingSpinner size="l" />}
{jobs.length !== 0 && singleSelection === true && renderJobsTable()}
{jobs.length !== 0 && singleSelection === undefined && renderTabs()}
{jobs.length !== 0 && !singleSelection && renderTabs()}
</Fragment>
);
}

View file

@ -4,20 +4,29 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { PropTypes } from 'prop-types';
import React, { FC, MouseEventHandler } from 'react';
import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { JobSelectorBadge } from '../job_selector_badge';
import { i18n } from '@kbn/i18n';
import { JobSelectorBadge } from '../job_selector_badge';
import { JobSelectionMaps } from '../job_selector';
export function NewSelectionIdBadges({
interface NewSelectionIdBadgesProps {
limit: number;
maps: JobSelectionMaps;
newSelection: string[];
onDeleteClick?: Function;
onLinkClick?: MouseEventHandler<HTMLAnchorElement>;
showAllBadges?: boolean;
}
export const NewSelectionIdBadges: FC<NewSelectionIdBadgesProps> = ({
limit,
maps,
newSelection,
onDeleteClick,
onLinkClick,
showAllBadges,
}) {
}) => {
const badges = [];
for (let i = 0; i < newSelection.length; i++) {
@ -60,16 +69,5 @@ export function NewSelectionIdBadges({
);
}
return badges;
}
NewSelectionIdBadges.propTypes = {
limit: PropTypes.number,
maps: PropTypes.shape({
jobsMap: PropTypes.object,
groupsMap: PropTypes.object,
}),
newSelection: PropTypes.array,
onDeleteClick: PropTypes.func,
onLinkClick: PropTypes.func,
showAllBadges: PropTypes.bool,
return <>{badges}</>;
};

View file

@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml';
import { kbnTypeToMLJobType } from '../../util/field_types_utils';
import { useTimefilter } from '../../contexts/kibana';
import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils';
import { TimeBuckets } from '../../util/time_buckets';
import { getTimeBucketsFromCache } from '../../util/time_buckets';
import { useUrlState } from '../../util/url_state';
import { FieldRequestConfig, FieldVisConfig } from './common';
import { ActionsPanel } from './components/actions_panel';
@ -318,7 +318,7 @@ export const Page: FC = () => {
// Obtain the interval to use for date histogram aggregations
// (such as the document count chart). Aim for 75 bars.
const buckets = new TimeBuckets();
const buckets = getTimeBucketsFromCache();
const tf = timefilter as any;
let earliest: number | undefined;

View file

@ -106,164 +106,6 @@
padding: 0;
margin-bottom: $euiSizeS;
div.ml-swimlanes {
margin: 0px 0px 0px 10px;
div.cells-marker-container {
margin-left: 176px;
height: 22px;
white-space: nowrap;
// background-color: #CCC;
.sl-cell {
height: 10px;
display: inline-block;
vertical-align: top;
margin-top: 16px;
text-align: center;
visibility: hidden;
cursor: default;
i {
color: $euiColorDarkShade;
}
}
.sl-cell-hover {
visibility: visible;
i {
display: block;
margin-top: -6px;
}
}
.sl-cell-active-hover {
visibility: visible;
.floating-time-label {
display: inline-block;
}
}
}
div.lane {
height: 30px;
border-bottom: 0px;
border-radius: 2px;
margin-top: -1px;
white-space: nowrap;
div.lane-label {
display: inline-block;
font-size: 13px;
height: 30px;
text-align: right;
vertical-align: middle;
border-radius: 2px;
padding-right: 5px;
margin-right: 5px;
border: 1px solid transparent;
overflow: hidden;
text-overflow: ellipsis;
}
div.lane-label.lane-label-masked {
opacity: 0.3;
}
div.cells-container {
border: $euiBorderThin;
border-right: 0px;
display: inline-block;
height: 30px;
vertical-align: middle;
background-color: $euiColorEmptyShade;
.sl-cell {
color: $euiColorEmptyShade;
cursor: default;
display: inline-block;
height: 29px;
border-right: $euiBorderThin;
vertical-align: top;
position: relative;
.sl-cell-inner,
.sl-cell-inner-dragselect {
height: 26px;
margin: 1px;
border-radius: 2px;
text-align: center;
}
.sl-cell-inner.sl-cell-inner-masked {
opacity: 0.2;
}
.sl-cell-inner.sl-cell-inner-selected,
.sl-cell-inner-dragselect.sl-cell-inner-selected {
border: 2px solid $euiColorDarkShade;
}
.sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked,
.sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked {
border: 2px solid $euiColorFullShade;
opacity: 0.4;
}
}
.sl-cell:hover {
.sl-cell-inner {
opacity: 0.8;
cursor: pointer;
}
}
.sl-cell.ds-selected {
.sl-cell-inner,
.sl-cell-inner-dragselect {
border: 2px solid $euiColorDarkShade;
border-radius: 2px;
opacity: 1;
}
}
}
}
div.lane:last-child {
div.cells-container {
.sl-cell {
border-bottom: $euiBorderThin;
}
}
}
.time-tick-labels {
height: 25px;
margin-top: $euiSizeXS / 2;
margin-left: 175px;
/* hide d3's domain line */
path.domain {
display: none;
}
/* hide d3's tick line */
g.tick line {
display: none;
}
/* override d3's default tick styles */
g.tick text {
font-size: 11px;
fill: $euiColorMediumShade;
}
}
}
line.gridLine {
stroke: $euiBorderColor;
fill: none;
@ -328,3 +170,161 @@
}
}
}
.ml-swimlanes {
margin: 0px 0px 0px 10px;
div.cells-marker-container {
margin-left: 176px;
height: 22px;
white-space: nowrap;
// background-color: #CCC;
.sl-cell {
height: 10px;
display: inline-block;
vertical-align: top;
margin-top: 16px;
text-align: center;
visibility: hidden;
cursor: default;
i {
color: $euiColorDarkShade;
}
}
.sl-cell-hover {
visibility: visible;
i {
display: block;
margin-top: -6px;
}
}
.sl-cell-active-hover {
visibility: visible;
.floating-time-label {
display: inline-block;
}
}
}
div.lane {
height: 30px;
border-bottom: 0px;
border-radius: 2px;
margin-top: -1px;
white-space: nowrap;
div.lane-label {
display: inline-block;
font-size: 13px;
height: 30px;
text-align: right;
vertical-align: middle;
border-radius: 2px;
padding-right: 5px;
margin-right: 5px;
border: 1px solid transparent;
overflow: hidden;
text-overflow: ellipsis;
}
div.lane-label.lane-label-masked {
opacity: 0.3;
}
div.cells-container {
border: $euiBorderThin;
border-right: 0px;
display: inline-block;
height: 30px;
vertical-align: middle;
background-color: $euiColorEmptyShade;
.sl-cell {
color: $euiColorEmptyShade;
cursor: default;
display: inline-block;
height: 29px;
border-right: $euiBorderThin;
vertical-align: top;
position: relative;
.sl-cell-inner,
.sl-cell-inner-dragselect {
height: 26px;
margin: 1px;
border-radius: 2px;
text-align: center;
}
.sl-cell-inner.sl-cell-inner-masked {
opacity: 0.2;
}
.sl-cell-inner.sl-cell-inner-selected,
.sl-cell-inner-dragselect.sl-cell-inner-selected {
border: 2px solid $euiColorDarkShade;
}
.sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked,
.sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked {
border: 2px solid $euiColorFullShade;
opacity: 0.4;
}
}
.sl-cell:hover {
.sl-cell-inner {
opacity: 0.8;
cursor: pointer;
}
}
.sl-cell.ds-selected {
.sl-cell-inner,
.sl-cell-inner-dragselect {
border: 2px solid $euiColorDarkShade;
border-radius: 2px;
opacity: 1;
}
}
}
}
div.lane:last-child {
div.cells-container {
.sl-cell {
border-bottom: $euiBorderThin;
}
}
}
.time-tick-labels {
height: 25px;
margin-top: $euiSizeXS / 2;
margin-left: 175px;
/* hide d3's domain line */
path.domain {
display: none;
}
/* hide d3's tick line */
g.tick line {
display: none;
}
/* override d3's default tick styles */
g.tick text {
font-size: 11px;
fill: $euiColorMediumShade;
}
}
}

View file

@ -36,9 +36,8 @@ import {
ExplorerNoJobsFound,
ExplorerNoResultsFound,
} from './components';
import { ChartTooltip } from '../components/chart_tooltip';
import { ExplorerSwimlane } from './explorer_swimlane';
import { TimeBuckets } from '../util/time_buckets';
import { getTimeBucketsFromCache } from '../util/time_buckets';
import { InfluencersList } from '../components/influencers_list';
import {
ALLOW_CELL_RANGE_SELECTION,
@ -81,6 +80,7 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public';
import { getTimefilter, getToastNotifications } from '../util/dependency_cache';
import { MlTooltipComponent } from '../components/chart_tooltip';
function mapSwimlaneOptionsToEuiOptions(options) {
return options.map(option => ({
@ -179,6 +179,8 @@ export class Explorer extends React.Component {
// Required to redraw the time series chart when the container is resized.
this.resizeChecker = new ResizeChecker(this.resizeRef.current);
this.resizeChecker.on('resize', this.resizeHandler);
this.timeBuckets = getTimeBucketsFromCache();
}
componentWillUnmount() {
@ -358,9 +360,6 @@ export class Explorer extends React.Component {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps} resizeRef={this.resizeRef}>
<div className="results-container">
{/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */}
<ChartTooltip />
{noInfluencersConfigured === false && influencers !== undefined && (
<div className="mlAnomalyExplorer__filterBar">
<ExplorerQueryBar
@ -418,17 +417,22 @@ export class Explorer extends React.Component {
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
{showOverallSwimlane && (
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
maskAll={maskAll}
TimeBuckets={TimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
<MlTooltipComponent>
{tooltipService => (
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
maskAll={maskAll}
timeBuckets={this.timeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={overallSwimlaneData}
swimlaneType={SWIMLANE_TYPE.OVERALL}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
)}
</div>
@ -494,17 +498,22 @@ export class Explorer extends React.Component {
onMouseLeave={this.onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
maskAll={maskAll}
TimeBuckets={TimeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
/>
<MlTooltipComponent>
{tooltipService => (
<ExplorerSwimlane
chartWidth={swimlaneContainerWidth}
filterActive={filterActive}
maskAll={maskAll}
timeBuckets={this.timeBuckets}
swimlaneCellClick={this.swimlaneCellClick}
swimlaneData={viewBySwimlaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
</div>
</>
)}

View file

@ -29,9 +29,8 @@ import {
removeLabelOverlap,
} from '../../util/chart_utils';
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
import { TimeBuckets } from '../../util/time_buckets';
import { getTimeBucketsFromCache } from '../../util/time_buckets';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { CHART_TYPE } from '../explorer_constants';
@ -50,6 +49,7 @@ export class ExplorerChartDistribution extends React.Component {
static propTypes = {
seriesConfig: PropTypes.object,
severity: PropTypes.number,
tooltipService: PropTypes.object.isRequired,
};
componentDidMount() {
@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component {
}
renderChart() {
const { tooManyBuckets } = this.props;
const { tooManyBuckets, tooltipService } = this.props;
const element = this.rootNode;
const config = this.props.seriesConfig;
@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component {
function drawRareChartAxes() {
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = new TimeBuckets();
const timeBuckets = getTimeBucketsFromCache();
const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) };
timeBuckets.setBounds(bounds);
timeBuckets.setInterval('auto');
@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component {
.on('mouseover', function(d) {
showLineChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => tooltipService.hide());
// Update all dots to new positions.
dots
@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component {
});
}
mlChartTooltipService.show(tooltipData, circle, {
tooltipService.show(tooltipData, circle, {
x: LINE_CHART_ANOMALY_RADIUS * 3,
y: LINE_CHART_ANOMALY_RADIUS * 2,
});

View file

@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json';
// Mock TimeBuckets and mlFieldFormatService, they don't play well
// with the jest based test setup yet.
jest.mock('../../util/time_buckets', () => ({
TimeBuckets: function() {
this.setBounds = jest.fn();
this.setInterval = jest.fn();
this.getScaledDateFormat = jest.fn();
},
getTimeBucketsFromCache: jest.fn(() => {
return {
setBounds: jest.fn(),
setInterval: jest.fn(),
getScaledDateFormat: jest.fn(),
};
}),
}));
jest.mock('../../services/field_format_service', () => ({
mlFieldFormatService: {
@ -43,8 +45,16 @@ describe('ExplorerChart', () => {
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
test('Initialize', () => {
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
const wrapper = mountWithIntl(
<ExplorerChartDistribution mlSelectSeverityService={mlSelectSeverityServiceMock} />
<ExplorerChartDistribution
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
);
// without setting any attributes and corresponding data
@ -59,10 +69,16 @@ describe('ExplorerChart', () => {
loading: true,
};
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
const wrapper = mountWithIntl(
<ExplorerChartDistribution
seriesConfig={config}
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
);
@ -83,12 +99,18 @@ describe('ExplorerChart', () => {
chartLimits: chartLimits(chartData),
};
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
// We create the element including a wrapper which sets the width:
return mountWithIntl(
<div style={{ width: '500px' }}>
<ExplorerChartDistribution
seriesConfig={config}
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
</div>
);

View file

@ -38,10 +38,9 @@ import {
showMultiBucketAnomalyTooltip,
} from '../../util/chart_utils';
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
import { TimeBuckets } from '../../util/time_buckets';
import { getTimeBucketsFromCache } from '../../util/time_buckets';
import { mlEscape } from '../../util/string_utils';
import { mlFieldFormatService } from '../../services/field_format_service';
import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service';
import { i18n } from '@kbn/i18n';
@ -53,6 +52,7 @@ export class ExplorerChartSingleMetric extends React.Component {
tooManyBuckets: PropTypes.bool,
seriesConfig: PropTypes.object,
severity: PropTypes.number.isRequired,
tooltipService: PropTypes.object.isRequired,
};
componentDidMount() {
@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component {
}
renderChart() {
const { tooManyBuckets } = this.props;
const { tooManyBuckets, tooltipService } = this.props;
const element = this.rootNode;
const config = this.props.seriesConfig;
@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component {
function drawLineChartAxes() {
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = new TimeBuckets();
const timeBuckets = getTimeBucketsFromCache();
const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) };
timeBuckets.setBounds(bounds);
timeBuckets.setInterval('auto');
@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component {
.on('mouseover', function(d) {
showLineChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => tooltipService.hide());
const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity;
@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component {
.on('mouseover', function(d) {
showLineChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => tooltipService.hide());
// Add rectangular markers for any scheduled events.
const scheduledEventMarkers = lineChartGroup
@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component {
});
}
mlChartTooltipService.show(tooltipData, circle, {
tooltipService.show(tooltipData, circle, {
x: LINE_CHART_ANOMALY_RADIUS * 3,
y: LINE_CHART_ANOMALY_RADIUS * 2,
});

View file

@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json';
// Mock TimeBuckets and mlFieldFormatService, they don't play well
// with the jest based test setup yet.
jest.mock('../../util/time_buckets', () => ({
TimeBuckets: function() {
this.setBounds = jest.fn();
this.setInterval = jest.fn();
this.getScaledDateFormat = jest.fn();
},
getTimeBucketsFromCache: jest.fn(() => {
return {
setBounds: jest.fn(),
setInterval: jest.fn(),
getScaledDateFormat: jest.fn(),
};
}),
}));
jest.mock('../../services/field_format_service', () => ({
mlFieldFormatService: {
@ -43,8 +45,16 @@ describe('ExplorerChart', () => {
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
test('Initialize', () => {
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
const wrapper = mountWithIntl(
<ExplorerChartSingleMetric mlSelectSeverityService={mlSelectSeverityServiceMock} />
<ExplorerChartSingleMetric
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
);
// without setting any attributes and corresponding data
@ -59,10 +69,16 @@ describe('ExplorerChart', () => {
loading: true,
};
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
const wrapper = mountWithIntl(
<ExplorerChartSingleMetric
seriesConfig={config}
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
);
@ -83,12 +99,18 @@ describe('ExplorerChart', () => {
chartLimits: chartLimits(chartData),
};
const mockTooltipService = {
show: jest.fn(),
hide: jest.fn(),
};
// We create the element including a wrapper which sets the width:
return mountWithIntl(
<div style={{ width: '500px' }}>
<ExplorerChartSingleMetric
seriesConfig={config}
mlSelectSeverityService={mlSelectSeverityServiceMock}
tooltipService={mockTooltipService}
/>
</div>
);

View file

@ -4,8 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import $ from 'jquery';
import React from 'react';
import {
@ -29,6 +27,7 @@ import { ExplorerChartLabel } from './components/explorer_chart_label';
import { CHART_TYPE } from '../explorer_constants';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { MlTooltipComponent } from '../../components/chart_tooltip';
const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', {
defaultMessage:
@ -121,19 +120,29 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel })
chartType === CHART_TYPE.POPULATION_DISTRIBUTION
) {
return (
<ExplorerChartDistribution
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
/>
<MlTooltipComponent>
{tooltipService => (
<ExplorerChartDistribution
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
);
}
return (
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
/>
<MlTooltipComponent>
{tooltipService => (
<ExplorerChartSingleMetric
tooManyBuckets={tooManyBuckets}
seriesConfig={series}
severity={severity}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
);
})()}
</React.Fragment>
@ -141,48 +150,36 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel })
}
// Flex layout wrapper for all explorer charts
export class ExplorerChartsContainer extends React.Component {
componentDidMount() {
// Create a div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
$('body').append(
'<div class="ml-explorer-tooltip ml-explorer-charts-tooltip" style="opacity:0; display: none;">'
);
}
export const ExplorerChartsContainer = ({
chartsPerRow,
seriesToPlot,
severity,
tooManyBuckets,
}) => {
// <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
// If that's the case we trick it doing that with the following settings:
const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto';
const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow;
componentWillUnmount() {
// Remove div for the tooltip.
$('.ml-explorer-charts-tooltip').remove();
}
const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series));
render() {
const { chartsPerRow, seriesToPlot, severity, tooManyBuckets } = this.props;
// <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1.
// If that's the case we trick it doing that with the following settings:
const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto';
const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow;
const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series));
return (
<EuiFlexGrid columns={chartsColumns}>
{seriesToPlot.length > 0 &&
seriesToPlot.map(series => (
<EuiFlexItem
key={getChartId(series)}
className="ml-explorer-chart-container"
style={{ minWidth: chartsWidth }}
>
<ExplorerChartContainer
series={series}
severity={severity}
tooManyBuckets={tooManyBuckets}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}
}
return (
<EuiFlexGrid columns={chartsColumns}>
{seriesToPlot.length > 0 &&
seriesToPlot.map(series => (
<EuiFlexItem
key={getChartId(series)}
className="ml-explorer-chart-container"
style={{ minWidth: chartsWidth }}
>
<ExplorerChartContainer
series={series}
severity={severity}
tooManyBuckets={tooManyBuckets}
wrapLabel={wrapLabel}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
);
};

View file

@ -21,11 +21,13 @@ import seriesConfigRare from './__mocks__/mock_series_config_rare.json';
// Mock TimeBuckets and mlFieldFormatService, they don't play well
// with the jest based test setup yet.
jest.mock('../../util/time_buckets', () => ({
TimeBuckets: function() {
this.setBounds = jest.fn();
this.setInterval = jest.fn();
this.getScaledDateFormat = jest.fn();
},
getTimeBucketsFromCache: jest.fn(() => {
return {
setBounds: jest.fn(),
setInterval: jest.fn(),
getScaledDateFormat: jest.fn(),
};
}),
}));
jest.mock('../../services/field_format_service', () => ({
mlFieldFormatService: {

View file

@ -14,7 +14,7 @@ export const DRAG_SELECT_ACTION = {
NEW_SELECTION: 'newSelection',
ELEMENT_SELECT: 'elementSelect',
DRAG_START: 'dragStart',
};
} as const;
export const EXPLORER_ACTION = {
CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings',
@ -37,10 +37,10 @@ export const FILTER_ACTION = {
REMOVE: '-',
};
export const SWIMLANE_TYPE = {
OVERALL: 'overall',
VIEW_BY: 'viewBy',
};
export enum SWIMLANE_TYPE {
OVERALL = 'overall',
VIEW_BY = 'viewBy',
}
export const CHART_TYPE = {
EVENT_DISTRIBUTION: 'event_distribution',

View file

@ -18,13 +18,16 @@ import { DeepPartial } from '../../../common/types/common';
import { jobSelectionActionCreator } from './actions';
import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service';
import { EXPLORER_ACTION } from './explorer_constants';
import { DRAG_SELECT_ACTION, EXPLORER_ACTION } from './explorer_constants';
import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils';
import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers';
export const ALLOW_CELL_RANGE_SELECTION = true;
export const dragSelect$ = new Subject();
export const dragSelect$ = new Subject<{
action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION];
elements?: any[];
}>();
type ExplorerAction = Action | Observable<ActionPayload>;
export const explorerAction$ = new Subject<ExplorerAction>();

View file

@ -12,6 +12,9 @@ import React from 'react';
import { dragSelect$ } from './explorer_dashboard_service';
import { ExplorerSwimlane } from './explorer_swimlane';
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
import { ChartTooltipService } from '../components/chart_tooltip';
import { OverallSwimlaneData } from './explorer_utils';
jest.mock('./explorer_dashboard_service', () => ({
dragSelect$: {
@ -22,32 +25,39 @@ jest.mock('./explorer_dashboard_service', () => ({
}));
function getExplorerSwimlaneMocks() {
const TimeBucketsMethods = {
const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData;
const timeBuckets = ({
setInterval: jest.fn(),
getScaledDateFormat: jest.fn(),
};
const TimeBuckets = jest.fn(() => TimeBucketsMethods);
TimeBuckets.mockMethods = TimeBucketsMethods;
} as unknown) as InstanceType<typeof TimeBucketsClass>;
const swimlaneData = { laneLabels: [] };
const tooltipService = ({
show: jest.fn(),
hide: jest.fn(),
} as unknown) as ChartTooltipService;
return {
TimeBuckets,
timeBuckets,
swimlaneData,
tooltipService,
};
}
const mockChartWidth = 800;
describe('ExplorerSwimlane', () => {
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 } as DOMRect;
// @ts-ignore
const originalGetBBox = SVGElement.prototype.getBBox;
beforeEach(() => {
moment.tz.setDefault('UTC');
// @ts-ignore
SVGElement.prototype.getBBox = () => mockedGetBBox;
});
afterEach(() => {
moment.tz.setDefault('Browser');
// @ts-ignore
SVGElement.prototype.getBBox = originalGetBBox;
});
@ -58,11 +68,12 @@ describe('ExplorerSwimlane', () => {
const wrapper = mountWithIntl(
<ExplorerSwimlane
chartWidth={mockChartWidth}
TimeBuckets={mocks.TimeBuckets}
timeBuckets={mocks.timeBuckets}
swimlaneCellClick={jest.fn()}
swimlaneData={mocks.swimlaneData}
swimlaneType="overall"
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
tooltipService={mocks.tooltipService}
/>
);
@ -72,12 +83,14 @@ describe('ExplorerSwimlane', () => {
);
// test calls to mock functions
// @ts-ignore
expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0);
expect(mocks.TimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(
mocks.TimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length
).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
});
@ -88,23 +101,26 @@ describe('ExplorerSwimlane', () => {
const wrapper = mountWithIntl(
<ExplorerSwimlane
chartWidth={mockChartWidth}
TimeBuckets={mocks.TimeBuckets}
timeBuckets={mocks.timeBuckets}
swimlaneCellClick={jest.fn()}
swimlaneData={mockOverallSwimlaneData}
swimlaneType="overall"
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
tooltipService={mocks.tooltipService}
/>
);
expect(wrapper.html()).toMatchSnapshot();
// test calls to mock functions
// @ts-ignore
expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0);
expect(mocks.TimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(
mocks.TimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length
).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
// @ts-ignore
expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
});
});

View file

@ -8,73 +8,99 @@
* React component for rendering Explorer dashboard swimlanes.
*/
import PropTypes from 'prop-types';
import React from 'react';
import './_explorer.scss';
import _ from 'lodash';
import d3 from 'd3';
import moment from 'moment';
// don't use something like plugins/ml/../common
// because it won't work with the jest tests
import { i18n } from '@kbn/i18n';
import { Subscription } from 'rxjs';
import { TooltipValue } from '@elastic/charts';
import { formatHumanReadableDateTime } from '../util/date_utils';
import { numTicksForDateFormat } from '../util/chart_utils';
import { getSeverityColor } from '../../../common/util/anomaly_utils';
import { mlEscape } from '../util/string_utils';
import { mlChartTooltipService } from '../components/chart_tooltip/chart_tooltip_service';
import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service';
import { DRAG_SELECT_ACTION } from './explorer_constants';
import { i18n } from '@kbn/i18n';
import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control';
import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets';
import {
ChartTooltipService,
ChartTooltipValue,
} from '../components/chart_tooltip/chart_tooltip_service';
import { OverallSwimlaneData } from './explorer_utils';
const SCSS = {
mlDragselectDragging: 'mlDragselectDragging',
mlHideRangeSelection: 'mlHideRangeSelection',
};
export class ExplorerSwimlane extends React.Component {
static propTypes = {
chartWidth: PropTypes.number.isRequired,
filterActive: PropTypes.bool,
maskAll: PropTypes.bool,
TimeBuckets: PropTypes.func.isRequired,
swimlaneCellClick: PropTypes.func.isRequired,
swimlaneData: PropTypes.shape({
laneLabels: PropTypes.array.isRequired,
}).isRequired,
swimlaneType: PropTypes.string.isRequired,
selection: PropTypes.object,
swimlaneRenderDoneListener: PropTypes.func.isRequired,
interface NodeWithData extends Node {
__clickData__: {
time: number;
bucketScore: number;
laneLabel: string;
swimlaneType: string;
};
}
interface SelectedData {
bucketScore: number;
laneLabels: string[];
times: number[];
}
export interface ExplorerSwimlaneProps {
chartWidth: number;
filterActive?: boolean;
maskAll?: boolean;
timeBuckets: InstanceType<typeof TimeBucketsClass>;
swimlaneCellClick?: Function;
swimlaneData: OverallSwimlaneData;
swimlaneType: string;
selection?: {
lanes: any[];
type: string;
times: number[];
};
swimlaneRenderDoneListener?: Function;
tooltipService: ChartTooltipService;
}
export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
// Since this component is mostly rendered using d3 and cellMouseoverActive is only
// relevant for d3 based interaction, we don't manage this using React's state
// and intentionally circumvent the component lifecycle when updating it.
cellMouseoverActive = true;
dragSelectSubscriber = null;
dragSelectSubscriber: Subscription | null = null;
rootNode = React.createRef<HTMLDivElement>();
componentDidMount() {
// property for data comparison to be able to filter
// consecutive click events with the same data.
let previousSelectedData = null;
let previousSelectedData: any = null;
// Listen for dragSelect events
this.dragSelectSubscriber = dragSelect$.subscribe(({ action, elements = [] }) => {
const element = d3.select(this.rootNode.parentNode);
const element = d3.select(this.rootNode.current!.parentNode!);
const { swimlaneType } = this.props;
if (action === DRAG_SELECT_ACTION.NEW_SELECTION && elements.length > 0) {
element.classed(SCSS.mlDragselectDragging, false);
const firstSelectedCell = d3.select(elements[0]).node().__clickData__;
const firstSelectedCell = (d3.select(elements[0]).node() as NodeWithData).__clickData__;
if (
typeof firstSelectedCell !== 'undefined' &&
swimlaneType === firstSelectedCell.swimlaneType
) {
const selectedData = elements.reduce(
const selectedData: SelectedData = elements.reduce(
(d, e) => {
const cell = d3.select(e).node().__clickData__;
const cell = (d3.select(e).node() as NodeWithData).__clickData__;
d.bucketScore = Math.max(d.bucketScore, cell.bucketScore);
d.laneLabels.push(cell.laneLabel);
d.times.push(cell.time);
@ -110,7 +136,7 @@ export class ExplorerSwimlane extends React.Component {
} else if (action === DRAG_SELECT_ACTION.DRAG_START) {
previousSelectedData = null;
this.cellMouseoverActive = false;
mlChartTooltipService.hide(true);
this.props.tooltipService.hide();
}
});
@ -125,12 +151,12 @@ export class ExplorerSwimlane extends React.Component {
if (this.dragSelectSubscriber !== null) {
this.dragSelectSubscriber.unsubscribe();
}
const element = d3.select(this.rootNode);
const element = d3.select(this.rootNode.current!);
element.html('');
}
selectCell(cellsToSelect, { laneLabels, bucketScore, times }) {
const { selection, swimlaneCellClick, swimlaneData, swimlaneType } = this.props;
selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) {
const { selection, swimlaneCellClick = () => {}, swimlaneData, swimlaneType } = this.props;
let triggerNewSelection = false;
@ -173,7 +199,7 @@ export class ExplorerSwimlane extends React.Component {
swimlaneCellClick(selectedCells);
}
highlightOverall(times) {
highlightOverall(times: number[]) {
const overallSwimlane = d3.select('.ml-swimlane-overall');
times.forEach(time => {
const overallCell = overallSwimlane
@ -183,7 +209,7 @@ export class ExplorerSwimlane extends React.Component {
});
}
highlightSelection(cellsToSelect, laneLabels, times) {
highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) {
const { swimlaneType } = this.props;
// This selects both overall and viewby swimlane
@ -204,8 +230,8 @@ export class ExplorerSwimlane extends React.Component {
.classed('sl-cell-inner-masked', false)
.classed('sl-cell-inner-selected', true);
const rootParent = d3.select(this.rootNode.parentNode);
rootParent.selectAll('.lane-label').classed('lane-label-masked', function() {
const rootParent = d3.select(this.rootNode.current!.parentNode!);
rootParent.selectAll('.lane-label').classed('lane-label-masked', function(this: HTMLElement) {
return laneLabels.indexOf(d3.select(this).text()) === -1;
});
@ -215,7 +241,7 @@ export class ExplorerSwimlane extends React.Component {
}
}
maskIrrelevantSwimlanes(maskAll) {
maskIrrelevantSwimlanes(maskAll: boolean) {
if (maskAll === true) {
// This selects both overall and viewby swimlane
const allSwimlanes = d3.selectAll('.ml-explorer-swimlane');
@ -248,7 +274,7 @@ export class ExplorerSwimlane extends React.Component {
}
renderSwimlane() {
const element = d3.select(this.rootNode.parentNode);
const element = d3.select(this.rootNode.current!.parentNode!);
// Consider the setting to support to select a range of cells
if (!ALLOW_CELL_RANGE_SELECTION) {
@ -263,7 +289,7 @@ export class ExplorerSwimlane extends React.Component {
chartWidth,
filterActive,
maskAll,
TimeBuckets,
timeBuckets,
swimlaneCellClick,
swimlaneData,
swimlaneType,
@ -278,11 +304,60 @@ export class ExplorerSwimlane extends React.Component {
points,
} = swimlaneData;
function colorScore(value) {
const cellMouseover = (
target: HTMLElement,
laneLabel: string,
bucketScore: number,
index: number,
time: number
) => {
if (bucketScore === undefined || getCellMouseoverActive() === false) {
return;
}
const displayScore = bucketScore > 1 ? parseInt(String(bucketScore), 10) : '< 1';
// Display date using same format as Kibana visualizations.
const formattedDate = formatHumanReadableDateTime(time * 1000);
const tooltipData: TooltipValue[] = [{ label: formattedDate } as TooltipValue];
if (swimlaneData.fieldName !== undefined) {
tooltipData.push({
label: swimlaneData.fieldName,
value: laneLabel,
// @ts-ignore
seriesIdentifier: {
key: laneLabel,
},
valueAccessor: 'fieldName',
});
}
tooltipData.push({
label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', {
defaultMessage: 'Max anomaly score',
}),
value: displayScore,
color: colorScore(bucketScore),
// @ts-ignore
seriesIdentifier: {
key: laneLabel,
},
valueAccessor: 'anomaly_score',
});
const offsets = target.className === 'sl-cell-inner' ? { x: 6, y: 0 } : { x: 8, y: 1 };
this.props.tooltipService.show(tooltipData, target, {
x: target.offsetWidth + offsets.x,
y: 6 + offsets.y,
});
};
function colorScore(value: number): string {
return getSeverityColor(value);
}
const numBuckets = parseInt((endTime - startTime) / stepSecs);
const numBuckets = Math.round((endTime - startTime) / stepSecs);
const cellHeight = 30;
const height = (lanes.length + 1) * cellHeight - 10;
const laneLabelWidth = 170;
@ -300,14 +375,13 @@ export class ExplorerSwimlane extends React.Component {
.range([0, xAxisWidth]);
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = new TimeBuckets();
timeBuckets.setInterval(`${stepSecs}s`);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
function cellMouseOverFactory(time, i) {
function cellMouseOverFactory(time: number, i: number) {
// Don't use an arrow function here because we need access to `this`,
// which is where d3 supplies a reference to the corresponding DOM element.
return function(lane) {
return function(this: HTMLElement, lane: string) {
const bucketScore = getBucketScore(lane, time);
if (bucketScore !== 0) {
lane = lane === '' ? EMPTY_FIELD_VALUE_LABEL : lane;
@ -316,49 +390,9 @@ export class ExplorerSwimlane extends React.Component {
};
}
function cellMouseover(target, laneLabel, bucketScore, index, time) {
if (bucketScore === undefined || getCellMouseoverActive() === false) {
return;
}
const displayScore = bucketScore > 1 ? parseInt(bucketScore) : '< 1';
// Display date using same format as Kibana visualizations.
const formattedDate = formatHumanReadableDateTime(time * 1000);
const tooltipData = [{ label: formattedDate }];
if (swimlaneData.fieldName !== undefined) {
tooltipData.push({
label: swimlaneData.fieldName,
value: laneLabel,
seriesIdentifier: {
key: laneLabel,
},
valueAccessor: 'fieldName',
});
}
tooltipData.push({
label: i18n.translate('xpack.ml.explorer.swimlane.maxAnomalyScoreLabel', {
defaultMessage: 'Max anomaly score',
}),
value: displayScore,
color: colorScore(displayScore),
seriesIdentifier: {
key: laneLabel,
},
valueAccessor: 'anomaly_score',
});
const offsets = target.className === 'sl-cell-inner' ? { x: 6, y: 0 } : { x: 8, y: 1 };
mlChartTooltipService.show(tooltipData, target, {
x: target.offsetWidth + offsets.x,
y: 6 + offsets.y,
});
}
function cellMouseleave() {
mlChartTooltipService.hide();
}
const cellMouseleave = () => {
this.props.tooltipService.hide();
};
const d3Lanes = swimlanes.selectAll('.lane').data(lanes);
const d3LanesEnter = d3Lanes
@ -366,11 +400,13 @@ export class ExplorerSwimlane extends React.Component {
.append('div')
.classed('lane', true);
const that = this;
d3LanesEnter
.append('div')
.classed('lane-label', true)
.style('width', `${laneLabelWidth}px`)
.html(label => {
.html((label: string) => {
const showFilterContext = filterActive === true && label === 'Overall';
if (showFilterContext) {
return i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', {
@ -382,20 +418,21 @@ export class ExplorerSwimlane extends React.Component {
}
})
.on('click', () => {
if (selection && typeof selection.lanes !== 'undefined') {
if (selection && typeof selection.lanes !== 'undefined' && swimlaneCellClick) {
swimlaneCellClick({});
}
})
.each(function() {
.each(function(this: HTMLElement) {
if (swimlaneData.fieldName !== undefined) {
d3.select(this)
.on('mouseover', value => {
mlChartTooltipService.show(
that.props.tooltipService.show(
[
{ skipHeader: true },
{ skipHeader: true } as ChartTooltipValue,
{
label: swimlaneData.fieldName,
label: swimlaneData.fieldName!,
value: value === '' ? EMPTY_FIELD_VALUE_LABEL : value,
// @ts-ignore
seriesIdentifier: { key: value },
valueAccessor: 'fieldName',
},
@ -408,15 +445,18 @@ export class ExplorerSwimlane extends React.Component {
);
})
.on('mouseout', () => {
mlChartTooltipService.hide();
that.props.tooltipService.hide();
})
.attr('aria-label', value => `${mlEscape(swimlaneData.fieldName)}: ${mlEscape(value)}`);
.attr(
'aria-label',
value => `${mlEscape(swimlaneData.fieldName!)}: ${mlEscape(value)}`
);
}
});
const cellsContainer = d3LanesEnter.append('div').classed('cells-container', true);
function getBucketScore(lane, time) {
function getBucketScore(lane: string, time: number): number {
let bucketScore = 0;
const point = points.find(p => {
return p.value > 0 && p.laneLabel === lane && p.time === time;
@ -436,16 +476,16 @@ export class ExplorerSwimlane extends React.Component {
.append('div')
.classed('sl-cell', true)
.style('width', `${cellWidth}px`)
.attr('data-lane-label', label => mlEscape(label))
.attr('data-lane-label', (label: string) => mlEscape(label))
.attr('data-time', time)
.attr('data-bucket-score', lane => {
.attr('data-bucket-score', (lane: string) => {
return getBucketScore(lane, time);
})
// use a factory here to bind the `time` and `i` values
// of this iteration to the event.
.on('mouseover', cellMouseOverFactory(time, i))
.on('mouseleave', cellMouseleave)
.each(function(laneLabel) {
.each(function(this: NodeWithData, laneLabel: string) {
this.__clickData__ = {
bucketScore: getBucketScore(laneLabel, time),
laneLabel,
@ -455,7 +495,7 @@ export class ExplorerSwimlane extends React.Component {
});
// calls itself with each() to get access to lane (= d3 data)
cell.append('div').each(function(lane) {
cell.append('div').each(function(this: HTMLElement, lane: string) {
const el = d3.select(this);
let color = 'none';
@ -505,13 +545,10 @@ export class ExplorerSwimlane extends React.Component {
// remove overlapping labels
let overlapCheck = 0;
gAxis.selectAll('g.tick').each(function() {
gAxis.selectAll('g.tick').each(function(this: HTMLElement) {
const tick = d3.select(this);
const xTransform = d3.transform(tick.attr('transform')).translate[0];
const tickWidth = tick
.select('text')
.node()
.getBBox().width;
const tickWidth = (tick.select('text').node() as SVGGraphicsElement).getBBox().width;
const xMinOffset = xTransform - tickWidth / 2;
const xMaxOffset = xTransform + tickWidth / 2;
// if the tick label overlaps the previous label
@ -541,7 +578,9 @@ export class ExplorerSwimlane extends React.Component {
element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
}
this.props.swimlaneRenderDoneListener();
if (this.props.swimlaneRenderDoneListener) {
this.props.swimlaneRenderDoneListener();
}
if (
(swimlaneType !== selectedType ||
@ -553,7 +592,7 @@ export class ExplorerSwimlane extends React.Component {
return;
}
const cellsToSelect = [];
const cellsToSelect: Node[] = [];
const selectedLanes = _.get(selectionState, 'lanes', []);
const selectedTimes = _.get(selectionState, 'times', []);
const selectedTimeExtent = d3.extent(selectedTimes);
@ -570,9 +609,9 @@ export class ExplorerSwimlane extends React.Component {
`div[data-lane-label="${mlEscape(selectedLane)}"]`
);
laneCells.each(function() {
laneCells.each(function(this: HTMLElement) {
const cell = d3.select(this);
const cellTime = cell.attr('data-time');
const cellTime = parseInt(cell.attr('data-time'), 10);
if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) {
cellsToSelect.push(cell.node());
}
@ -585,7 +624,7 @@ export class ExplorerSwimlane extends React.Component {
}, 0);
const selectedCellTimes = cellsToSelect.map(e => {
return d3.select(e).node().__clickData__.time;
return (d3.select(e).node() as NodeWithData).__clickData__.time;
});
if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) {
@ -594,7 +633,7 @@ export class ExplorerSwimlane extends React.Component {
if (selectedCellTimes.length > 0) {
this.highlightOverall(selectedCellTimes);
}
this.maskIrrelevantSwimlanes(maskAll);
this.maskIrrelevantSwimlanes(!!maskAll);
} else {
this.clearSelection();
}
@ -604,15 +643,9 @@ export class ExplorerSwimlane extends React.Component {
return true;
}
setRef(componentNode) {
this.rootNode = componentNode;
}
render() {
const { swimlaneType } = this.props;
return (
<div className={`ml-swimlanes ml-swimlane-${swimlaneType}`} ref={this.setRef.bind(this)} />
);
return <div className={`ml-swimlanes ml-swimlane-${swimlaneType}`} ref={this.rootNode} />;
}
}

View file

@ -17,10 +17,16 @@ interface ClearedSelectedAnomaliesState {
export declare const getClearedSelectedAnomaliesState: () => ClearedSelectedAnomaliesState;
export interface SwimlanePoint {
laneLabel: string;
time: number;
value: number;
}
export declare interface SwimlaneData {
fieldName: string;
fieldName?: string;
laneLabels: string[];
points: any[];
points: SwimlanePoint[];
interval: number;
}

View file

@ -26,7 +26,7 @@ import { parseInterval } from '../../../common/util/parse_interval';
import { ml } from '../services/ml_api_service';
import { mlJobService } from '../services/job_service';
import { mlResultsService } from '../services/results_service';
import { getBoundsRoundedToInterval, TimeBuckets } from '../util/time_buckets';
import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../util/time_buckets';
import { getTimefilter, getUiSettings } from '../util/dependency_cache';
import {
@ -235,7 +235,7 @@ export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth)
// and the max bucket span for the jobs shown in the chart.
const timefilter = getTimefilter();
const bounds = timefilter.getActiveBounds();
const buckets = new TimeBuckets();
const buckets = getTimeBucketsFromCache();
buckets.setInterval('auto');
buckets.setBounds(bounds);

View file

@ -11,7 +11,7 @@ import {
isMultiMetricJobCreator,
isPopulationJobCreator,
} from '../../../../common/job_creator';
import { TimeBuckets } from '../../../../../../util/time_buckets';
import { getTimeBucketsFromCache, TimeBuckets } from '../../../../../../util/time_buckets';
import { useUiSettings } from '../../../../../../contexts/kibana/use_ui_settings_context';
export function useChartColors() {
@ -72,7 +72,7 @@ export function getChartSettings(jobCreator: JobCreatorType, chartInterval: Time
// the calculation from TimeBuckets, but without the
// bar target and max bars which have been set for the
// general chartInterval
const interval = new TimeBuckets();
const interval = getTimeBucketsFromCache();
interval.setInterval('auto');
interval.setBounds(chartInterval.getBounds());
cs.intervalMs = interval.getInterval().asMilliseconds();

View file

@ -35,7 +35,7 @@ import { ResultsLoader } from '../../common/results_loader';
import { JobValidator } from '../../common/job_validator';
import { useMlContext } from '../../../../contexts/ml';
import { getTimeFilterRange } from '../../../../components/full_time_range_selector';
import { TimeBuckets } from '../../../../util/time_buckets';
import { getTimeBucketsFromCache } from '../../../../util/time_buckets';
import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_service';
import { expandCombinedJobConfig } from '../../../../../../common/types/anomaly_detection_jobs';
import { newJobCapsService } from '../../../../services/new_job_capabilities_service';
@ -174,7 +174,7 @@ export const Page: FC<PageProps> = ({ existingJobsAndGroups, jobType }) => {
}
}
const chartInterval = new TimeBuckets();
const chartInterval = getTimeBucketsFromCache();
chartInterval.setBarTarget(BAR_TARGET);
chartInterval.setMaxBars(MAX_BARS);
chartInterval.setInterval('auto');

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Job, JobId } from '../../../common/types/anomaly_detection_jobs';
import { basePath } from './ml_api_service';
import { HttpService } from './http_service';
export class AnomalyDetectorService {
private readonly apiBasePath = basePath() + '/anomaly_detectors';
constructor(private httpService: HttpService) {}
/**
* Fetches a single job object
* @param jobId
*/
getJobById$(jobId: JobId): Observable<Job> {
return this.httpService
.http$<{ count: number; jobs: Job[] }>({
path: `${this.apiBasePath}/${jobId}`,
})
.pipe(map(response => response.jobs[0]));
}
/**
* Fetches anomaly detection jobs by ids
* @param jobIds
*/
getJobs$(jobIds: JobId[]): Observable<Job[]> {
return this.httpService
.http$<{ count: number; jobs: Job[] }>({
path: `${this.apiBasePath}/${jobIds.join(',')}`,
})
.pipe(map(response => response.jobs));
}
/**
* Extract unique influencers from the job or collection of jobs
* @param jobs
*/
extractInfluencers(jobs: Job | Job[]): string[] {
if (!Array.isArray(jobs)) {
jobs = [jobs];
}
const influencers = new Set<string>();
for (const job of jobs) {
for (const influencer of job.analysis_config.influencers) {
influencers.add(influencer);
}
}
return Array.from(influencers);
}
}

View file

@ -0,0 +1,308 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IUiSettingsClient } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public';
import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets';
import { ExplorerJob, OverallSwimlaneData, SwimlaneData } from '../explorer/explorer_utils';
import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants';
import { MlResultsService } from './results_service';
/**
* Anomaly Explorer Service
*/
export class ExplorerService {
private timeBuckets: TimeBuckets;
private _customTimeRange: TimeRange | undefined;
constructor(
private timeFilter: TimefilterContract,
uiSettings: IUiSettingsClient,
private mlResultsService: MlResultsService
) {
this.timeBuckets = new TimeBuckets({
'histogram:maxBars': uiSettings.get('histogram:maxBars'),
'histogram:barTarget': uiSettings.get('histogram:barTarget'),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
this.timeFilter.enableTimeRangeSelector();
}
public setTimeRange(timeRange: TimeRange) {
this._customTimeRange = timeRange;
}
public getSwimlaneBucketInterval(selectedJobs: ExplorerJob[], swimlaneContainerWidth: number) {
// Bucketing interval should be the maximum of the chart related interval (i.e. time range related)
// and the max bucket span for the jobs shown in the chart.
const bounds = this.getTimeBounds();
if (bounds === undefined) {
throw new Error('timeRangeSelectorEnabled has to be enabled');
}
this.timeBuckets.setInterval('auto');
this.timeBuckets.setBounds(bounds);
const intervalSeconds = this.timeBuckets.getInterval().asSeconds();
// if the swimlane cell widths are too small they will not be visible
// calculate how many buckets will be drawn before the swimlanes are actually rendered
// and increase the interval to widen the cells if they're going to be smaller than 8px
// this has to be done at this stage so all searches use the same interval
const timerangeSeconds = (bounds.max!.valueOf() - bounds.min!.valueOf()) / 1000;
const numBuckets = timerangeSeconds / intervalSeconds;
const cellWidth = Math.floor((swimlaneContainerWidth / numBuckets) * 100) / 100;
// if the cell width is going to be less than 8px, double the interval
if (cellWidth < 8) {
this.timeBuckets.setInterval(intervalSeconds * 2 + 's');
}
const maxBucketSpanSeconds = selectedJobs.reduce(
(memo, job) => Math.max(memo, job.bucketSpanSeconds),
0
);
if (maxBucketSpanSeconds > intervalSeconds) {
this.timeBuckets.setInterval(maxBucketSpanSeconds + 's');
this.timeBuckets.setBounds(bounds);
}
return this.timeBuckets.getInterval();
}
/**
* Loads overall swimlane data
* @param selectedJobs
* @param chartWidth
*/
public async loadOverallData(
selectedJobs: ExplorerJob[],
chartWidth: number
): Promise<OverallSwimlaneData> {
const interval = this.getSwimlaneBucketInterval(selectedJobs, chartWidth);
if (!selectedJobs || !selectedJobs.length) {
throw new Error('Explorer jobs collection is required');
}
const bounds = this.getTimeBounds();
// Ensure the search bounds align to the bucketing interval used in the swimlane so
// that the first and last buckets are complete.
const searchBounds = getBoundsRoundedToInterval(bounds, interval, false);
const selectedJobIds = selectedJobs.map(d => d.id);
// Load the overall bucket scores by time.
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
// which wouldn't be the case if e.g. '1M' was used.
// Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works
// to ensure the search is inclusive of end time.
const overallBucketsBounds = getBoundsRoundedToInterval(bounds, interval, true);
const resp = await this.mlResultsService.getOverallBucketScores(
selectedJobIds,
// Note there is an optimization for when top_n == 1.
// If top_n > 1, we should test what happens when the request takes long
// and refactor the loading calls, if necessary, to avoid delays in loading other components.
1,
overallBucketsBounds.min.valueOf(),
overallBucketsBounds.max.valueOf(),
interval.asSeconds() + 's'
);
const overallSwimlaneData = this.processOverallResults(
resp.results,
searchBounds,
interval.asSeconds()
);
// eslint-disable-next-line no-console
console.log('Explorer overall swimlane data set:', overallSwimlaneData);
return overallSwimlaneData;
}
public async loadViewBySwimlane(
fieldValues: string[],
bounds: { earliest: number; latest: number },
selectedJobs: ExplorerJob[],
viewBySwimlaneFieldName: string,
swimlaneLimit: number,
swimlaneContainerWidth: number,
influencersFilterQuery?: any
): Promise<SwimlaneData | undefined> {
const timefilterBounds = this.getTimeBounds();
if (timefilterBounds === undefined) {
throw new Error('timeRangeSelectorEnabled has to be enabled');
}
const swimlaneBucketInterval = this.getSwimlaneBucketInterval(
selectedJobs,
swimlaneContainerWidth
);
const searchBounds = getBoundsRoundedToInterval(
timefilterBounds,
swimlaneBucketInterval,
false
);
const selectedJobIds = selectedJobs.map(d => d.id);
// load scores by influencer/jobId value and time.
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
// which wouldn't be the case if e.g. '1M' was used.
const interval = `${swimlaneBucketInterval.asSeconds()}s`;
let response;
if (viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL) {
const jobIds =
fieldValues !== undefined && fieldValues.length > 0 ? fieldValues : selectedJobIds;
response = await this.mlResultsService.getScoresByBucket(
jobIds,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit
);
} else {
response = await this.mlResultsService.getInfluencerValueMaxScoreByTime(
selectedJobIds,
viewBySwimlaneFieldName,
fieldValues,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit,
influencersFilterQuery
);
}
if (response === undefined) {
return;
}
const viewBySwimlaneData = this.processViewByResults(
response.results,
fieldValues,
bounds,
viewBySwimlaneFieldName,
swimlaneBucketInterval.asSeconds()
);
// eslint-disable-next-line no-console
console.log('Explorer view by swimlane data set:', viewBySwimlaneData);
return viewBySwimlaneData;
}
private getTimeBounds(): TimeRangeBounds {
return this._customTimeRange !== undefined
? this.timeFilter.calculateBounds(this._customTimeRange)
: this.timeFilter.getBounds();
}
private processOverallResults(
scoresByTime: { [timeMs: number]: number },
searchBounds: Required<TimeRangeBounds>,
interval: number
): OverallSwimlaneData {
const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', {
defaultMessage: 'Overall',
});
const dataset: OverallSwimlaneData = {
laneLabels: [overallLabel],
points: [],
interval,
earliest: searchBounds.min.valueOf() / 1000,
latest: searchBounds.max.valueOf() / 1000,
};
// Store the earliest and latest times of the data returned by the ES aggregations,
// These will be used for calculating the earliest and latest times for the swimlane charts.
Object.entries(scoresByTime).forEach(([timeMs, score]) => {
const time = Number(timeMs) / 1000;
dataset.points.push({
laneLabel: overallLabel,
time,
value: score,
});
dataset.earliest = Math.min(time, dataset.earliest);
dataset.latest = Math.max(time + dataset.interval, dataset.latest);
});
return dataset;
}
private processViewByResults(
scoresByInfluencerAndTime: Record<string, { [timeMs: number]: number }>,
sortedLaneValues: string[],
bounds: any,
viewBySwimlaneFieldName: string,
interval: number
): OverallSwimlaneData {
// Processes the scores for the 'view by' swimlane.
// Sorts the lanes according to the supplied array of lane
// values in the order in which they should be displayed,
// or pass an empty array to sort lanes according to max score over all time.
const dataset: OverallSwimlaneData = {
fieldName: viewBySwimlaneFieldName,
points: [],
laneLabels: [],
interval,
// Set the earliest and latest to be the same as the overall swimlane.
earliest: bounds.earliest,
latest: bounds.latest,
};
const maxScoreByLaneLabel: Record<string, number> = {};
Object.entries(scoresByInfluencerAndTime).forEach(([influencerFieldValue, influencerData]) => {
dataset.laneLabels.push(influencerFieldValue);
maxScoreByLaneLabel[influencerFieldValue] = 0;
Object.entries(influencerData).forEach(([timeMs, anomalyScore]) => {
const time = Number(timeMs) / 1000;
dataset.points.push({
laneLabel: influencerFieldValue,
time,
value: anomalyScore,
});
maxScoreByLaneLabel[influencerFieldValue] = Math.max(
maxScoreByLaneLabel[influencerFieldValue],
anomalyScore
);
});
});
const sortValuesLength = sortedLaneValues.length;
if (sortValuesLength === 0) {
// Sort lanes in descending order of max score.
// Note the keys in scoresByInfluencerAndTime received from the ES request
// are not guaranteed to be sorted by score if they can be parsed as numbers
// (e.g. if viewing by HTTP response code).
dataset.laneLabels = dataset.laneLabels.sort((a, b) => {
return maxScoreByLaneLabel[b] - maxScoreByLaneLabel[a];
});
} else {
// Sort lanes according to supplied order
// e.g. when a cell in the overall swimlane has been selected.
// Find the index of each lane label from the actual data set,
// rather than using sortedLaneValues as-is, just in case they differ.
dataset.laneLabels = dataset.laneLabels.sort((a, b) => {
let aIndex = sortedLaneValues.indexOf(a);
let bIndex = sortedLaneValues.indexOf(b);
aIndex = aIndex > -1 ? aIndex : sortValuesLength;
bIndex = bIndex > -1 ? bIndex : sortValuesLength;
return aIndex - bIndex;
});
}
return dataset;
}
}

View file

@ -5,7 +5,7 @@
*/
import { Observable } from 'rxjs';
import { HttpFetchOptionsWithPath, HttpFetchOptions } from 'kibana/public';
import { HttpFetchOptionsWithPath, HttpFetchOptions, HttpStart } from 'kibana/public';
import { getHttp } from '../util/dependency_cache';
function getResultHeaders(headers: HeadersInit): HeadersInit {
@ -102,3 +102,105 @@ export function fromHttpHandler<T>(input: string, init?: RequestInit): Observabl
};
});
}
/**
* ML Http Service
*/
export class HttpService {
constructor(private httpStart: HttpStart) {}
private getResultHeaders(headers: HeadersInit): HeadersInit {
return {
asSystemRequest: true,
'Content-Type': 'application/json',
...headers,
} as HeadersInit;
}
private getFetchOptions(
options: HttpFetchOptionsWithPath
): { path: string; fetchOptions: HttpFetchOptions } {
if (!options.path) {
throw new Error('URL path is missing');
}
return {
path: options.path,
fetchOptions: {
credentials: 'same-origin',
method: options.method || 'GET',
...(options.body ? { body: options.body } : {}),
...(options.query ? { query: options.query } : {}),
headers: this.getResultHeaders(options.headers ?? {}),
},
};
}
/**
* Creates an Observable from Kibana's HttpHandler.
*/
private fromHttpHandler<T>(input: string, init?: RequestInit): Observable<T> {
return new Observable<T>(subscriber => {
const controller = new AbortController();
const signal = controller.signal;
let abortable = true;
let unsubscribed = false;
if (init?.signal) {
if (init.signal.aborted) {
controller.abort();
} else {
init.signal.addEventListener('abort', () => {
if (!signal.aborted) {
controller.abort();
}
});
}
}
const perSubscriberInit: RequestInit = {
...(init ? init : {}),
signal,
};
this.httpStart
.fetch<T>(input, perSubscriberInit)
.then(response => {
abortable = false;
subscriber.next(response);
subscriber.complete();
})
.catch(err => {
abortable = false;
if (!unsubscribed) {
subscriber.error(err);
}
});
return () => {
unsubscribed = true;
if (abortable) {
controller.abort();
}
};
});
}
/**
* Function for making HTTP requests to Kibana's backend.
* Wrapper for Kibana's HttpHandler.
*/
public async http<T>(options: HttpFetchOptionsWithPath): Promise<T> {
const { path, fetchOptions } = this.getFetchOptions(options);
return this.httpStart.fetch<T>(path, fetchOptions);
}
/**
* Function for making HTTP requests to Kibana's backend which returns an Observable
* with request cancellation support.
*/
public http$<T>(options: HttpFetchOptionsWithPath): Observable<T> {
const { path, fetchOptions } = this.getFetchOptions(options);
return this.fromHttpHandler<T>(path, fetchOptions);
}
}

View file

@ -46,6 +46,8 @@ export const mlResultsService = {
fetchPartitionFieldsValues,
};
export type MlResultsService = typeof mlResultsService;
type time = string;
export interface ModelPlotOutputResults {
results: Record<time, { actual: number; modelUpper: number | null; modelLower: number | null }>;

View file

@ -20,7 +20,16 @@ export function getOverallBucketScores(
latestMs: any,
interval?: any
): Promise<any>;
export function getInfluencerValueMaxScoreByTime(): Promise<any>;
export function getInfluencerValueMaxScoreByTime(
jobIds: string[],
influencerFieldName: string,
influencerFieldValues: string[],
earliestMs: number,
latestMs: number,
interval: string,
maxResults: number,
influencersFilterQuery: any
): Promise<any>;
export function getRecordInfluencers(): Promise<any>;
export function getRecordsForInfluencer(): Promise<any>;
export function getRecordsForDetector(): Promise<any>;

View file

@ -8,9 +8,11 @@ import d3 from 'd3';
import { Annotation } from '../../../../../common/types/annotations';
import { CombinedJob } from '../../../../../common/types/anomaly_detection_jobs';
import { ChartTooltipService } from '../../../components/chart_tooltip';
interface Props {
selectedJob: CombinedJob;
tooltipService: ChartTooltipService;
}
interface State {

View file

@ -33,13 +33,12 @@ import {
showMultiBucketAnomalyTooltip,
} from '../../../util/chart_utils';
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
import { TimeBuckets } from '../../../util/time_buckets';
import { getTimeBucketsFromCache } from '../../../util/time_buckets';
import { mlTableService } from '../../../services/table_service';
import { ContextChartMask } from '../context_chart_mask';
import { findChartPointForAnomalyTime } from '../../timeseriesexplorer_utils';
import { mlEscape } from '../../../util/string_utils';
import { mlFieldFormatService } from '../../../services/field_format_service';
import { mlChartTooltipService } from '../../../components/chart_tooltip/chart_tooltip_service';
import {
ANNOTATION_MASK_ID,
getAnnotationBrush,
@ -113,6 +112,7 @@ class TimeseriesChartIntl extends Component {
zoomTo: PropTypes.object,
zoomFromFocusLoaded: PropTypes.object,
zoomToFocusLoaded: PropTypes.object,
tooltipService: PropTypes.object.isRequired,
};
rowMouseenterSubscriber = null;
@ -582,6 +582,8 @@ class TimeseriesChartIntl extends Component {
const contextYScale = this.contextYScale;
const showFocusChartTooltip = this.showFocusChartTooltip.bind(this);
const hideFocusChartTooltip = this.props.tooltipService.hide.bind(this.props.tooltipService);
const focusChart = d3.select('.focus-chart');
// Update the plot interval labels.
@ -691,7 +693,7 @@ class TimeseriesChartIntl extends Component {
}
// Get the scaled date format to use for x axis tick labels.
const timeBuckets = new TimeBuckets();
const timeBuckets = getTimeBucketsFromCache();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
@ -719,7 +721,8 @@ class TimeseriesChartIntl extends Component {
focusChartHeight,
this.focusXScale,
showAnnotations,
showFocusChartTooltip
showFocusChartTooltip,
hideFocusChartTooltip
);
// disable brushing (creation of annotations) when annotations aren't shown
@ -753,7 +756,7 @@ class TimeseriesChartIntl extends Component {
.on('mouseover', function(d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => this.props.tooltipService.hide());
// Update all dots to new positions.
dots
@ -794,7 +797,7 @@ class TimeseriesChartIntl extends Component {
.on('mouseover', function(d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => this.props.tooltipService.hide());
// Update all markers to new positions.
multiBucketMarkers
@ -854,7 +857,7 @@ class TimeseriesChartIntl extends Component {
.on('mouseover', function(d) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide());
.on('mouseout', () => this.props.tooltipService.hide());
// Update all dots to new positions.
forecastDots
@ -1019,7 +1022,7 @@ class TimeseriesChartIntl extends Component {
.attr('y2', cxtChartHeight + swlHeight);
// Add x axis.
const timeBuckets = new TimeBuckets();
const timeBuckets = getTimeBucketsFromCache();
timeBuckets.setInterval('auto');
timeBuckets.setBounds(bounds);
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
@ -1635,7 +1638,7 @@ class TimeseriesChartIntl extends Component {
}
}
mlChartTooltipService.show(tooltipData, circle, {
this.props.tooltipService.show(tooltipData, circle, {
x: xOffset,
y: 0,
});
@ -1713,7 +1716,7 @@ class TimeseriesChartIntl extends Component {
d3.select('.focus-chart-markers')
.selectAll('.anomaly-marker.highlighted')
.remove();
mlChartTooltipService.hide();
this.props.tooltipService.hide();
}
shouldComponentUpdate() {

View file

@ -11,9 +11,6 @@ import { ANNOTATION_TYPE } from '../../../../../common/constants/annotations';
import { Annotation, Annotations } from '../../../../../common/types/annotations';
import { Dictionary } from '../../../../../common/types/common';
// @ts-ignore
import { mlChartTooltipService } from '../../../components/chart_tooltip/chart_tooltip_service';
import { TimeseriesChart } from './timeseries_chart';
import { annotation$ } from '../../../services/annotations_service';
@ -110,7 +107,8 @@ export function renderAnnotations(
focusChartHeight: number,
focusXScale: TimeseriesChart['focusXScale'],
showAnnotations: boolean,
showFocusChartTooltip: (d: Annotation, t: object) => {}
showFocusChartTooltip: (d: Annotation, t: object) => {},
hideFocusChartTooltip: () => void
) {
const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN;
const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN;
@ -156,7 +154,7 @@ export function renderAnnotations(
.on('mouseover', function(this: object, d: Annotation) {
showFocusChartTooltip(d, this);
})
.on('mouseout', () => mlChartTooltipService.hide())
.on('mouseout', () => hideFocusChartTooltip())
.on('click', (d: Annotation) => {
// clear a possible existing annotation set up for editing before setting the new one.
// this needs to be done explicitly here because a new annotation created using the brush tool

View file

@ -44,7 +44,7 @@ import {
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
import { AnnotationsTable } from '../components/annotations/annotations_table';
import { AnomaliesTable } from '../components/anomalies_table/anomalies_table';
import { ChartTooltip } from '../components/chart_tooltip';
import { MlTooltipComponent } from '../components/chart_tooltip';
import { EntityControl } from './components/entity_control';
import { ForecastingModal } from './components/forecasting_modal/forecasting_modal';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
@ -1204,9 +1204,6 @@ export class TimeSeriesExplorer extends React.Component {
(fullRefresh === false || loading === false) &&
hasResults === true && (
<div>
{/* Make sure ChartTooltip is inside this plain wrapping element without padding so positioning can be inferred correctly. */}
<ChartTooltip />
<div className="results-container">
<EuiTitle className="panel-title">
<h2 style={{ display: 'inline' }}>
@ -1301,16 +1298,21 @@ export class TimeSeriesExplorer extends React.Component {
)}
</EuiFlexGroup>
<div className="ml-timeseries-chart" data-test-subj="mlSingleMetricViewerChart">
<TimeseriesChart
{...chartProps}
bounds={bounds}
detectorIndex={selectedDetectorIndex}
renderFocusChartOnly={renderFocusChartOnly}
selectedJob={selectedJob}
showAnnotations={showAnnotations}
showForecast={showForecast}
showModelBounds={showModelBounds}
/>
<MlTooltipComponent>
{tooltipService => (
<TimeseriesChart
{...chartProps}
bounds={bounds}
detectorIndex={selectedDetectorIndex}
renderFocusChartOnly={renderFocusChartOnly}
selectedJob={selectedJob}
showAnnotations={showAnnotations}
showForecast={showForecast}
showModelBounds={showModelBounds}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
</div>
{showAnnotations && focusAnnotationData.length > 0 && (
<div>

View file

@ -16,7 +16,7 @@ import moment from 'moment-timezone';
import { isTimeSeriesViewJob } from '../../../../common/util/job_utils';
import { parseInterval } from '../../../../common/util/parse_interval';
import { TimeBuckets, getBoundsRoundedToInterval } from '../../util/time_buckets';
import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../../util/time_buckets';
import { CHARTS_POINT_TARGET, TIME_FIELD_NAME } from '../timeseriesexplorer_constants';
@ -283,7 +283,7 @@ export function calculateAggregationInterval(bounds, bucketsTarget, jobs, select
const barTarget = bucketsTarget !== undefined ? bucketsTarget : 100;
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * barTarget);
const buckets = new TimeBuckets();
const buckets = getTimeBucketsFromCache();
buckets.setInterval('auto');
buckets.setBounds(bounds);
buckets.setBarTarget(Math.floor(barTarget));
@ -378,7 +378,7 @@ export function getAutoZoomDuration(jobs, selectedJob) {
// Use a maxBars of 10% greater than the target.
const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET);
const buckets = new TimeBuckets();
const buckets = getTimeBucketsFromCache();
buckets.setInterval('auto');
buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET));
buckets.setMaxBars(maxBars);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number;

View file

@ -13,7 +13,7 @@ export function formatHumanReadableDate(ts: number) {
return formatDate(ts, 'MMMM Do YYYY');
}
export function formatHumanReadableDateTime(ts: number) {
export function formatHumanReadableDateTime(ts: number): string {
return formatDate(ts, 'MMMM Do YYYY, HH:mm');
}

View file

@ -6,9 +6,9 @@
import { Moment } from 'moment';
declare interface TimeRangeBounds {
min: Moment | undefined;
max: Moment | undefined;
export interface TimeRangeBounds {
min?: Moment;
max?: Moment;
}
export declare interface TimeBucketsInterval {
@ -17,11 +17,28 @@ export declare interface TimeBucketsInterval {
expression: string;
}
export class TimeBuckets {
setBarTarget: (barTarget: number) => void;
setMaxBars: (maxBars: number) => void;
setInterval: (interval: string) => void;
setBounds: (bounds: TimeRangeBounds) => void;
getBounds: () => { min: any; max: any };
getInterval: () => TimeBucketsInterval;
export interface TimeBucketsConfig {
'histogram:maxBars': number;
'histogram:barTarget': number;
dateFormat: string;
'dateFormat:scaled': string[][];
}
export declare class TimeBuckets {
constructor(timeBucketsConfig: TimeBucketsConfig);
public setBarTarget(barTarget: number): void;
public setMaxBars(maxBars: number): void;
public setInterval(interval: string): void;
public setBounds(bounds: TimeRangeBounds): void;
public getBounds(): { min: any; max: any };
public getInterval(): TimeBucketsInterval;
public getScaledDateFormat(): string;
}
export declare function getTimeBucketsFromCache(): InstanceType<typeof TimeBuckets>;
export declare function getBoundsRoundedToInterval(
bounds: TimeRangeBounds,
interval: TimeBucketsInterval,
inclusiveEnd?: boolean
): Required<TimeRangeBounds>;

View file

@ -18,15 +18,25 @@ const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer con
const calcAuto = timeBucketsCalcAutoIntervalProvider();
export function getTimeBucketsFromCache() {
const uiSettings = getUiSettings();
return new TimeBuckets({
'histogram:maxBars': uiSettings.get('histogram:maxBars'),
'histogram:barTarget': uiSettings.get('histogram:barTarget'),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}
/**
* Helper object for wrapping the concept of an "Interval", which
* describes a timespan that will separate buckets of time,
* for example the interval between points on a time series chart.
*/
export function TimeBuckets() {
const uiSettings = getUiSettings();
this.barTarget = uiSettings.get('histogram:barTarget');
this.maxBars = uiSettings.get('histogram:maxBars');
export function TimeBuckets(timeBucketsConfig) {
this._timeBucketsConfig = timeBucketsConfig;
this.barTarget = this._timeBucketsConfig['histogram:barTarget'];
this.maxBars = this._timeBucketsConfig['histogram:maxBars'];
}
/**
@ -297,9 +307,8 @@ TimeBuckets.prototype.getIntervalToNearestMultiple = function(divisorSecs) {
* @return {string}
*/
TimeBuckets.prototype.getScaledDateFormat = function() {
const uiSettings = getUiSettings();
const interval = this.getInterval();
const rules = uiSettings.get('dateFormat:scaled');
const rules = this._timeBucketsConfig['dateFormat:scaled'];
for (let i = rules.length - 1; i >= 0; i--) {
const rule = rules[i];
@ -308,19 +317,18 @@ TimeBuckets.prototype.getScaledDateFormat = function() {
}
}
return uiSettings.get('dateFormat');
return this._timeBucketsConfig.dateFormat;
};
TimeBuckets.prototype.getScaledDateFormatter = function() {
const fieldFormats = getFieldFormats();
const uiSettings = getUiSettings();
const DateFieldFormat = fieldFormats.getType(FIELD_FORMAT_IDS.DATE);
return new DateFieldFormat(
{
pattern: this.getScaledDateFormat(),
},
// getConfig
uiSettings.get
this._timeBucketsConfig
);
};

View file

@ -7,34 +7,20 @@
import moment from 'moment';
import { TimeBuckets, getBoundsRoundedToInterval, calcEsInterval } from './time_buckets';
jest.mock(
'./dependency_cache',
() => ({
getUiSettings: () => {
return {
get(val) {
switch (val) {
case 'histogram:barTarget':
return 50;
case 'histogram:maxBars':
return 100;
}
},
};
},
}),
{ virtual: true }
);
describe('ML - time buckets', () => {
let autoBuckets;
let customBuckets;
beforeEach(() => {
autoBuckets = new TimeBuckets();
const timeBucketsConfig = {
'histogram:maxBars': 100,
'histogram:barTarget': 50,
};
autoBuckets = new TimeBuckets(timeBucketsConfig);
autoBuckets.setInterval('auto');
customBuckets = new TimeBuckets();
customBuckets = new TimeBuckets(timeBucketsConfig);
customBuckets.setInterval('auto');
customBuckets.setBarTarget(500);
customBuckets.setMaxBars(550);

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart } from 'kibana/public';
import { Subject } from 'rxjs';
import {
Embeddable,
EmbeddableInput,
EmbeddableOutput,
IContainer,
} from '../../../../../../src/plugins/embeddable/public';
import { MlStartDependencies } from '../../plugin';
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { JobId } from '../../../common/types/anomaly_detection_jobs';
import { ExplorerService } from '../../application/services/explorer_service';
import {
Filter,
Query,
RefreshInterval,
TimeRange,
} from '../../../../../../src/plugins/data/common';
export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane';
export interface AnomalySwimlaneEmbeddableCustomInput {
jobIds: JobId[];
swimlaneType: string;
viewBy?: string;
limit?: number;
// Embeddable inputs which are not included in the default interface
filters: Filter[];
query: Query;
refreshConfig: RefreshInterval;
timeRange: TimeRange;
}
export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput;
export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput {
jobIds: JobId[];
swimlaneType: string;
viewBy?: string;
limit?: number;
}
export interface AnomalySwimlaneServices {
anomalyDetectorService: AnomalyDetectorService;
explorerService: ExplorerService;
}
export type AnomalySwimlaneEmbeddableServices = [
CoreStart,
MlStartDependencies,
AnomalySwimlaneServices
];
export class AnomalySwimlaneEmbeddable extends Embeddable<
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput
> {
private node?: HTMLElement;
private reload$ = new Subject();
public readonly type: string = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE;
constructor(
initialInput: AnomalySwimlaneEmbeddableInput,
private services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices],
parent?: IContainer
) {
super(
initialInput,
{
jobIds: initialInput.jobIds,
swimlaneType: initialInput.swimlaneType,
defaultTitle: initialInput.title,
...(initialInput.viewBy ? { viewBy: initialInput.viewBy } : {}),
},
parent
);
}
public render(node: HTMLElement) {
super.render(node);
this.node = node;
ReactDOM.render(
<ExplorerSwimlaneContainer
id={this.input.id}
embeddableInput={this.getInput$()}
services={this.services}
refresh={this.reload$.asObservable()}
onOutputChange={output => this.updateOutput(output)}
/>,
node
);
}
public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
public reload() {
this.reload$.next();
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane_embeddable_factory';
import { coreMock } from '../../../../../../src/core/public/mocks';
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import {
AnomalySwimlaneEmbeddable,
AnomalySwimlaneEmbeddableInput,
} from './anomaly_swimlane_embeddable';
jest.mock('./anomaly_swimlane_embeddable', () => ({
AnomalySwimlaneEmbeddable: jest.fn(),
}));
describe('AnomalySwimlaneEmbeddableFactory', () => {
test('should provide required services on create', async () => {
// arrange
const pluginStartDeps = { data: dataPluginMock.createStartContract() };
const getStartServices = coreMock.createSetup({
pluginStartDeps,
}).getStartServices;
const [coreStart, pluginsStart] = await getStartServices();
// act
const factory = new AnomalySwimlaneEmbeddableFactory(getStartServices);
await factory.create({
jobIds: ['test-job'],
} as AnomalySwimlaneEmbeddableInput);
// assert
const mockCalls = ((AnomalySwimlaneEmbeddable as unknown) as jest.Mock<
AnomalySwimlaneEmbeddable
>).mock.calls[0];
const input = mockCalls[0];
const createServices = mockCalls[1];
expect(input).toEqual({
jobIds: ['test-job'],
});
expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart));
expect(createServices[1]).toMatchObject(pluginsStart);
expect(Object.keys(createServices[2])).toEqual(['anomalyDetectorService', 'explorerService']);
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { StartServicesAccessor } from 'kibana/public';
import {
EmbeddableFactoryDefinition,
ErrorEmbeddable,
IContainer,
} from '../../../../../../src/plugins/embeddable/public';
import {
ANOMALY_SWIMLANE_EMBEDDABLE_TYPE,
AnomalySwimlaneEmbeddable,
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableServices,
} from './anomaly_swimlane_embeddable';
import { MlStartDependencies } from '../../plugin';
import { HttpService } from '../../application/services/http_service';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { ExplorerService } from '../../application/services/explorer_service';
import { mlResultsService } from '../../application/services/results_service';
import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout';
export class AnomalySwimlaneEmbeddableFactory
implements EmbeddableFactoryDefinition<AnomalySwimlaneEmbeddableInput> {
public readonly type = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE;
constructor(private getStartServices: StartServicesAccessor<MlStartDependencies>) {}
public async isEditable() {
return true;
}
public getDisplayName() {
return i18n.translate('xpack.ml.components.jobAnomalyScoreEmbeddable.displayName', {
defaultMessage: 'ML Anomaly Swimlane',
});
}
public async getExplicitInput(): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
const [{ overlays, uiSettings }, , { anomalyDetectorService }] = await this.getServices();
try {
return await resolveAnomalySwimlaneUserInput({
anomalyDetectorService,
overlays,
uiSettings,
});
} catch (e) {
return Promise.reject();
}
}
private async getServices(): Promise<AnomalySwimlaneEmbeddableServices> {
const [coreStart, pluginsStart] = await this.getStartServices();
const httpService = new HttpService(coreStart.http);
const anomalyDetectorService = new AnomalyDetectorService(httpService);
const explorerService = new ExplorerService(
pluginsStart.data.query.timefilter.timefilter,
coreStart.uiSettings,
// TODO mlResultsService to use DI
mlResultsService
);
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];
}
public async create(
initialInput: AnomalySwimlaneEmbeddableInput,
parent?: IContainer
): Promise<AnomalySwimlaneEmbeddable | ErrorEmbeddable> {
const services = await this.getServices();
return new AnomalySwimlaneEmbeddable(initialInput, services, parent);
}
}

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonGroup,
EuiForm,
EuiFormRow,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSelect,
EuiFieldText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable';
export interface AnomalySwimlaneInitializerProps {
defaultTitle: string;
influencers: string[];
initialInput?: Partial<
Pick<AnomalySwimlaneEmbeddableInput, 'jobIds' | 'swimlaneType' | 'viewBy' | 'limit'>
>;
onCreate: (swimlaneProps: {
panelTitle: string;
swimlaneType: string;
viewBy?: string;
limit?: number;
}) => void;
onCancel: () => void;
}
const limitOptions = [5, 10, 25, 50].map(limit => ({
value: limit,
text: `${limit}`,
}));
export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = ({
defaultTitle,
influencers,
onCreate,
onCancel,
initialInput,
}) => {
const [panelTitle, setPanelTitle] = useState(defaultTitle);
const [swimlaneType, setSwimlaneType] = useState<SWIMLANE_TYPE>(
(initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE
);
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
const swimlaneTypeOptions = [
{
id: SWIMLANE_TYPE.OVERALL,
label: i18n.translate('xpack.ml.explorer.overallLabel', {
defaultMessage: 'Overall',
}),
},
{
id: SWIMLANE_TYPE.VIEW_BY,
label: i18n.translate('xpack.ml.explorer.viewByLabel', {
defaultMessage: 'View by',
}),
},
];
const viewBySwimlaneOptions = ['', ...influencers].map(influencer => {
return {
value: influencer,
text: influencer,
};
});
const isPanelTitleValid = panelTitle.length > 0;
const isFormValid =
isPanelTitleValid &&
(swimlaneType === SWIMLANE_TYPE.OVERALL ||
(swimlaneType === SWIMLANE_TYPE.VIEW_BY && !!viewBySwimlaneFieldName));
return (
<div>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.title"
defaultMessage="Anomaly swimlane configuration"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.panelTitleLabel"
defaultMessage="Panel title"
/>
}
isInvalid={!isPanelTitleValid}
>
<EuiFieldText
id="panelTitle"
name="panelTitle"
value={panelTitle}
onChange={e => setPanelTitle(e.target.value)}
isInvalid={!isPanelTitleValid}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel"
defaultMessage="Swimlane type"
/>
}
>
<EuiButtonGroup
id="selectSwimlaneType"
name="selectSwimlaneType"
color="primary"
isFullWidth
legend={i18n.translate('xpack.ml.swimlaneEmbeddable.setupModal.swimlaneTypeLabel', {
defaultMessage: 'Swimlane type',
})}
options={swimlaneTypeOptions}
idSelected={swimlaneType}
onChange={id => setSwimlaneType(id as SWIMLANE_TYPE)}
/>
</EuiFormRow>
{swimlaneType === SWIMLANE_TYPE.VIEW_BY && (
<>
<EuiFormRow
label={
<FormattedMessage id="xpack.ml.explorer.viewByLabel" defaultMessage="View by" />
}
>
<EuiSelect
id="selectViewBy"
name="selectViewBy"
options={viewBySwimlaneOptions}
value={viewBySwimlaneFieldName}
onChange={e => setViewBySwimlaneFieldName(e.target.value)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage id="xpack.ml.explorer.limitLabel" defaultMessage="Limit" />
}
>
<EuiSelect
id="limit"
name="limit"
options={limitOptions}
value={limit}
onChange={e => setLimit(Number(e.target.value))}
/>
</EuiFormRow>
</>
)}
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
isDisabled={!isFormValid}
onClick={onCreate.bind(null, {
panelTitle,
swimlaneType,
viewBy: viewBySwimlaneFieldName,
limit,
})}
fill
>
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.setupModal.confirmButtonLabel"
defaultMessage="Confirm"
/>
</EuiButton>
</EuiModalFooter>
</div>
);
};

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer';
import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector';
import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable';
export async function resolveAnomalySwimlaneUserInput(
{
overlays,
anomalyDetectorService,
uiSettings,
}: {
anomalyDetectorService: AnomalyDetectorService;
overlays: OverlayStart;
uiSettings: IUiSettingsClient;
},
input?: AnomalySwimlaneEmbeddableInput
): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
return new Promise(async (resolve, reject) => {
const maps = {
groupsMap: getInitialGroupsMap([]),
jobsMap: {},
};
const tzConfig = uiSettings.get('dateFormat:tz');
const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess();
const selectedIds = input?.jobIds;
const flyoutSession = overlays.openFlyout(
toMountPoint(
<JobSelectorFlyout
selectedIds={selectedIds}
withTimeRangeSelector={false}
dateFormatTz={dateFormatTz}
singleSelection={false}
timeseriesOnly={true}
onFlyoutClose={() => {
flyoutSession.close();
reject();
}}
onSelectionConfirmed={async ({ jobIds, groups }) => {
const title =
input?.title ??
i18n.translate('xpack.ml.swimlaneEmbeddable.title', {
defaultMessage: 'ML anomaly swimlane for {jobIds}',
values: { jobIds: jobIds.join(', ') },
});
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
const influencers = anomalyDetectorService.extractInfluencers(jobs);
influencers.push(VIEW_BY_JOB_LABEL);
await flyoutSession.close();
const modalSession = overlays.openModal(
toMountPoint(
<AnomalySwimlaneInitializer
defaultTitle={title}
influencers={influencers}
initialInput={input}
onCreate={({ panelTitle, viewBy, swimlaneType, limit }) => {
modalSession.close();
resolve({ jobIds, title: panelTitle, swimlaneType, viewBy, limit });
}}
onCancel={() => {
modalSession.close();
reject();
}}
/>
)
);
}}
maps={maps}
/>
),
{
'data-test-subj': 'mlAnomalySwimlaneEmbeddable',
}
);
});
}

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
import { BehaviorSubject, Observable } from 'rxjs';
import { I18nProvider } from '@kbn/i18n/react';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { CoreStart } from 'kibana/public';
import { MlStartDependencies } from '../../plugin';
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
jest.mock('./swimlane_input_resolver', () => ({
useSwimlaneInputResolver: jest.fn(() => {
return [];
}),
}));
jest.mock('../../application/explorer/explorer_swimlane', () => ({
ExplorerSwimlane: jest.fn(),
}));
jest.mock('../../application/components/chart_tooltip', () => ({
MlTooltipComponent: jest.fn(),
}));
const defaultOptions = { wrapper: I18nProvider };
describe('ExplorerSwimlaneContainer', () => {
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: BehaviorSubject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
beforeEach(() => {
embeddableInput = new BehaviorSubject({
id: 'test-swimlane-embeddable',
} as Partial<AnomalySwimlaneEmbeddableInput>);
});
test('should render a swimlane with a valid embeddable input', async () => {
const mockOverallData = {
laneLabels: ['Overall'],
points: [
{
laneLabel: 'Overall',
time: 1572825600,
value: 55.00448,
},
],
interval: 345600,
earliest: 1572134400,
latest: 1588377599.999,
};
(useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([
mockOverallData,
SWIMLANE_TYPE.OVERALL,
undefined,
]);
const { findByTestId } = render(
<ExplorerSwimlaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
/>,
defaultOptions
);
expect(
await findByTestId('mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
).toBeDefined();
});
test('should render an error in case it could not fetch the ML swimlane data', async () => {
(useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([
undefined,
undefined,
undefined,
{ message: 'Something went wrong' },
]);
const { findByText } = render(
<ExplorerSwimlaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
/>,
defaultOptions
);
const errorMessage = await findByText('Something went wrong');
expect(errorMessage).toBeDefined();
});
test('should render a loading indicator during the data fetching', async () => {
const { findByTestId } = render(
<ExplorerSwimlaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
/>,
defaultOptions
);
expect(
await findByTestId('loading_mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
).toBeDefined();
});
});

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState } from 'react';
import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingChart,
EuiResizeObserver,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { Observable } from 'rxjs';
import { throttle } from 'lodash';
import { CoreStart } from 'kibana/public';
import { FormattedMessage } from '@kbn/i18n/react';
import { ExplorerSwimlane } from '../../application/explorer/explorer_swimlane';
import { MlStartDependencies } from '../../plugin';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
const RESIZE_THROTTLE_TIME_MS = 500;
export interface ExplorerSwimlaneContainerProps {
id: string;
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>;
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
refresh: Observable<any>;
onOutputChange?: (output: Partial<AnomalySwimlaneEmbeddableOutput>) => void;
}
export const ExplorerSwimlaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
id,
embeddableInput,
services,
refresh,
}) => {
const [chartWidth, setChartWidth] = useState<number>(0);
const [swimlaneType, swimlaneData, timeBuckets, error] = useSwimlaneInputResolver(
embeddableInput,
refresh,
services,
chartWidth
);
const onResize = throttle((e: { width: number; height: number }) => {
const labelWidth = 200;
setChartWidth(e.width - labelWidth);
}, RESIZE_THROTTLE_TIME_MS);
if (error) {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.errorMessage"
defaultMessage="Unable to load the ML swimlane data"
/>
}
color="danger"
iconType="alert"
style={{ width: '100%' }}
>
<p>{error.message}</p>
</EuiCallOut>
);
}
return (
<EuiResizeObserver onResize={onResize}>
{resizeRef => (
<div
style={{ width: '100%', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
data-test-subj={`mlMaxAnomalyScoreEmbeddable_${id}`}
ref={el => {
resizeRef(el);
}}
>
<div style={{ width: '100%' }}>
<EuiSpacer size="m" />
{chartWidth > 0 && swimlaneData && swimlaneType ? (
<EuiText color="subdued" size="s">
<MlTooltipComponent>
{tooltipService => (
<ExplorerSwimlane
chartWidth={chartWidth}
timeBuckets={timeBuckets}
swimlaneData={swimlaneData}
swimlaneType={swimlaneType}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
</EuiText>
) : (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingChart
size="xl"
data-test-subj={`loading_mlMaxAnomalyScoreEmbeddable_${id}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
</div>
)}
</EuiResizeObserver>
);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane_embeddable_factory';
export { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from './anomaly_swimlane_embeddable';

View file

@ -0,0 +1,271 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { processFilters, useSwimlaneInputResolver } from './swimlane_input_resolver';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { CoreStart, IUiSettingsClient } from 'kibana/public';
import { MlStartDependencies } from '../../plugin';
describe('useSwimlaneInputResolver', () => {
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: Subject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
beforeEach(() => {
jest.useFakeTimers();
embeddableInput = new BehaviorSubject({
id: 'test-swimlane-embeddable',
jobIds: ['test-job'],
swimlaneType: SWIMLANE_TYPE.OVERALL,
filters: [],
query: { language: 'kuery', query: '' },
} as Partial<AnomalySwimlaneEmbeddableInput>);
refresh = new Subject();
services = [
{
uiSettings: ({
get: jest.fn(() => {
return null;
}),
} as unknown) as IUiSettingsClient,
} as CoreStart,
(null as unknown) as MlStartDependencies,
({
explorerService: {
setTimeRange: jest.fn(),
loadOverallData: jest.fn(() =>
Promise.resolve({
earliest: 0,
latest: 0,
points: [],
interval: 3600,
})
),
loadViewBySwimlane: jest.fn(() =>
Promise.resolve({
points: [],
})
),
},
anomalyDetectorService: {
getJobs$: jest.fn(() =>
of([
{
job_id: 'cw_multi_1',
analysis_config: { bucket_span: '15m' },
},
])
),
},
} as unknown) as AnomalySwimlaneServices,
];
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('should fetch jobs only when input job ids have been changed', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSwimlaneInputResolver(
embeddableInput as Observable<AnomalySwimlaneEmbeddableInput>,
refresh,
services,
1000
)
);
expect(result.current[0]).toBe(undefined);
expect(result.current[1]).toBe(undefined);
await act(async () => {
jest.advanceTimersByTime(501);
await waitForNextUpdate();
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(1);
await act(async () => {
embeddableInput.next({
id: 'test-swimlane-embeddable',
jobIds: ['another-id'],
swimlaneType: SWIMLANE_TYPE.OVERALL,
filters: [],
query: { language: 'kuery', query: '' },
});
jest.advanceTimersByTime(501);
await waitForNextUpdate();
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(2);
await act(async () => {
embeddableInput.next({
id: 'test-swimlane-embeddable',
jobIds: ['another-id'],
swimlaneType: SWIMLANE_TYPE.OVERALL,
filters: [],
query: { language: 'kuery', query: '' },
});
jest.advanceTimersByTime(501);
await waitForNextUpdate();
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(3);
});
});
describe('processFilters', () => {
test('should format embeddable input to es query', () => {
expect(
processFilters(
[
{
meta: {
index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114',
type: 'phrases',
key: 'instance',
value: 'i-20d061fa',
params: ['i-20d061fa'],
alias: null,
negate: false,
disabled: false,
},
query: {
bool: {
should: [
{
match_phrase: {
instance: 'i-20d061fa',
},
},
],
minimum_should_match: 1,
},
},
// @ts-ignore
$state: {
store: 'appState',
},
},
{
meta: {
index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114',
alias: null,
negate: true,
disabled: false,
type: 'phrase',
key: 'instance',
params: {
query: 'i-16fd8d2a',
},
},
query: {
match_phrase: {
instance: 'i-16fd8d2a',
},
},
// @ts-ignore
$state: {
store: 'appState',
},
},
{
meta: {
index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114',
alias: null,
negate: false,
disabled: false,
type: 'exists',
key: 'instance',
value: 'exists',
},
exists: {
field: 'instance',
},
// @ts-ignore
$state: {
store: 'appState',
},
},
{
meta: {
index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114',
alias: null,
negate: false,
disabled: true,
type: 'exists',
key: 'instance',
value: 'exists',
},
exists: {
field: 'region',
},
// @ts-ignore
$state: {
store: 'appState',
},
},
],
{
language: 'kuery',
query: 'instance : "i-088147ac"',
}
)
).toEqual({
bool: {
must: [
{
bool: {
minimum_should_match: 1,
should: [
{
match_phrase: {
instance: 'i-088147ac',
},
},
],
},
},
{
bool: {
should: [
{
match_phrase: {
instance: 'i-20d061fa',
},
},
],
minimum_should_match: 1,
},
},
{
exists: {
field: 'instance',
},
},
],
must_not: [
{
match_phrase: {
instance: 'i-16fd8d2a',
},
},
],
},
});
});
});

View file

@ -0,0 +1,211 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useMemo, useState } from 'react';
import { combineLatest, from, Observable, of, Subject } from 'rxjs';
import { isEqual } from 'lodash';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
pluck,
skipWhile,
startWith,
switchMap,
} from 'rxjs/operators';
import { CoreStart } from 'kibana/public';
import { TimeBuckets } from '../../application/util/time_buckets';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { MlStartDependencies } from '../../plugin';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters';
import { Query } from '../../../../../../src/plugins/data/common/query';
import { esKuery } from '../../../../../../src/plugins/data/public';
import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils';
import { parseInterval } from '../../../common/util/parse_interval';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
const RESIZE_IGNORED_DIFF_PX = 20;
const FETCH_RESULTS_DEBOUNCE_MS = 500;
function getJobsObservable(
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>,
anomalyDetectorService: AnomalyDetectorService
) {
return embeddableInput.pipe(
pluck('jobIds'),
distinctUntilChanged(isEqual),
switchMap(jobsIds => anomalyDetectorService.getJobs$(jobsIds))
);
}
export function useSwimlaneInputResolver(
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>,
refresh: Observable<any>,
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices],
chartWidth: number
) {
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
const [swimlaneType, setSwimlaneType] = useState<string>();
const [error, setError] = useState();
const chartWidth$ = useMemo(() => new Subject<number>(), []);
const timeBuckets = useMemo(() => {
return new TimeBuckets({
'histogram:maxBars': uiSettings.get('histogram:maxBars'),
'histogram:barTarget': uiSettings.get('histogram:barTarget'),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}, []);
useEffect(() => {
const subscription = combineLatest([
getJobsObservable(embeddableInput, anomalyDetectorService),
embeddableInput,
chartWidth$.pipe(
skipWhile(v => !v),
distinctUntilChanged((prev, curr) => {
// emit only if the width has been changed significantly
return Math.abs(curr - prev) < RESIZE_IGNORED_DIFF_PX;
})
),
refresh.pipe(startWith(null)),
])
.pipe(
debounceTime(FETCH_RESULTS_DEBOUNCE_MS),
switchMap(([jobs, input, swimlaneContainerWidth]) => {
const {
viewBy,
swimlaneType: swimlaneTypeInput,
limit,
timeRange,
filters,
query,
} = input;
explorerService.setTimeRange(timeRange);
if (!swimlaneType) {
setSwimlaneType(swimlaneTypeInput);
}
const explorerJobs: ExplorerJob[] = jobs.map(job => {
const bucketSpan = parseInterval(job.analysis_config.bucket_span);
return {
id: job.job_id,
selected: true,
bucketSpanSeconds: bucketSpan!.asSeconds(),
};
});
let appliedFilters: any;
try {
appliedFilters = processFilters(filters, query);
} catch (e) {
// handle query syntax errors
setError(e);
return of(undefined);
}
return from(explorerService.loadOverallData(explorerJobs, swimlaneContainerWidth)).pipe(
switchMap(overallSwimlaneData => {
const { earliest, latest } = overallSwimlaneData;
if (overallSwimlaneData && swimlaneTypeInput === SWIMLANE_TYPE.VIEW_BY) {
return from(
explorerService.loadViewBySwimlane(
[],
{ earliest, latest },
explorerJobs,
viewBy!,
limit!,
swimlaneContainerWidth,
appliedFilters
)
).pipe(
map(viewBySwimlaneData => {
return {
...viewBySwimlaneData!,
earliest,
latest,
};
})
);
}
return of(overallSwimlaneData);
})
);
}),
catchError(e => {
setError(e.body);
return of(undefined);
})
)
.subscribe(data => {
if (data !== undefined) {
setError(null);
setSwimlaneData(data);
}
});
return () => {
subscription.unsubscribe();
};
}, []);
useEffect(() => {
chartWidth$.next(chartWidth);
}, [chartWidth]);
return [swimlaneType, swimlaneData, timeBuckets, error];
}
export function processFilters(filters: Filter[], query: Query) {
const inputQuery =
query.language === 'kuery'
? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string))
: query.query;
const must = [inputQuery];
const mustNot = [];
for (const filter of filters) {
if (filter.meta.disabled) continue;
const {
meta: { negate, type, key: fieldName },
} = filter;
let filterQuery = filter.query;
if (filterQuery === undefined && type === 'exists') {
filterQuery = {
exists: {
field: fieldName,
},
};
}
if (negate) {
mustNot.push(filterQuery);
} else {
must.push(filterQuery);
}
}
return {
bool: {
must,
must_not: mustNot,
},
};
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'kibana/public';
import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane';
import { MlPluginStart, MlStartDependencies } from '../plugin';
import { EmbeddableSetup } from '../../../../../src/plugins/embeddable/public';
export function registerEmbeddables(
embeddable: EmbeddableSetup,
core: CoreSetup<MlStartDependencies, MlPluginStart>
) {
const anomalySwimlaneEmbeddableFactory = new AnomalySwimlaneEmbeddableFactory(
core.getStartServices
);
embeddable.registerEmbeddableFactory(
anomalySwimlaneEmbeddableFactory.type,
anomalySwimlaneEmbeddableFactory
);
}

View file

@ -12,6 +12,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { DataPublicPluginStart } from 'src/plugins/data/public';
import { HomePublicPluginSetup } from 'src/plugins/home/public';
import { EmbeddableSetup } from 'src/plugins/embeddable/public';
import { SecurityPluginSetup } from '../../security/public';
import { LicensingPluginSetup } from '../../licensing/public';
import { initManagementSection } from './application/management';
@ -19,6 +20,9 @@ import { LicenseManagementUIPluginSetup } from '../../license_management/public'
import { setDependencyCache } from './application/util/dependency_cache';
import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app';
import { registerFeature } from './register_feature';
import { registerEmbeddables } from './embeddables';
import { UiActionsSetup } from '../../../../src/plugins/ui_actions/public';
import { registerMlUiActions } from './ui_actions';
export interface MlStartDependencies {
data: DataPublicPluginStart;
@ -31,6 +35,8 @@ export interface MlSetupDependencies {
usageCollection: UsageCollectionSetup;
licenseManagement?: LicenseManagementUIPluginSetup;
home: HomePublicPluginSetup;
embeddable: EmbeddableSetup;
uiActions: UiActionsSetup;
}
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
@ -57,6 +63,8 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
usageCollection: pluginsSetup.usageCollection,
licenseManagement: pluginsSetup.licenseManagement,
home: pluginsSetup.home,
embeddable: pluginsSetup.embeddable,
uiActions: pluginsSetup.uiActions,
},
{
element: params.element,
@ -71,6 +79,11 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
registerFeature(pluginsSetup.home);
initManagementSection(pluginsSetup, core);
registerMlUiActions(pluginsSetup.uiActions, core);
registerEmbeddables(pluginsSetup.embeddable, core);
return {};
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public';
import { IEmbeddable } from '../../../../../src/plugins/embeddable/public';
import {
AnomalySwimlaneEmbeddable,
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput,
} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout';
import { HttpService } from '../application/services/http_service';
import { AnomalyDetectorService } from '../application/services/anomaly_detector_service';
export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction';
export interface EditSwimlanePanelContext {
embeddable: IEmbeddable<AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput>;
}
export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getStartServices']) {
return createAction<typeof EDIT_SWIMLANE_PANEL_ACTION>({
id: 'edit-anomaly-swimlane',
type: EDIT_SWIMLANE_PANEL_ACTION,
getIconType(context: ActionContextMapping[typeof EDIT_SWIMLANE_PANEL_ACTION]): string {
return 'pencil';
},
getDisplayName: () =>
i18n.translate('xpack.ml.actions.editSwimlaneTitle', {
defaultMessage: 'Edit swimlane',
}),
execute: async ({ embeddable }: EditSwimlanePanelContext) => {
if (!embeddable) {
throw new Error('Not possible to execute an action without the embeddable context');
}
const [{ overlays, uiSettings, http }] = await getStartServices();
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
try {
const result = await resolveAnomalySwimlaneUserInput(
{
anomalyDetectorService,
overlays,
uiSettings,
},
embeddable.getInput()
);
embeddable.updateInput(result);
} catch (e) {
return Promise.reject();
}
},
isCompatible: async ({ embeddable }: EditSwimlanePanelContext) => {
return (
embeddable instanceof AnomalySwimlaneEmbeddable && embeddable.getInput().viewMode === 'edit'
);
},
});
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'kibana/public';
import {
createEditSwimlanePanelAction,
EDIT_SWIMLANE_PANEL_ACTION,
EditSwimlanePanelContext,
} from './edit_swimlane_panel_action';
import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public';
import { MlPluginStart, MlStartDependencies } from '../plugin';
import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public';
export function registerMlUiActions(
uiActions: UiActionsSetup,
core: CoreSetup<MlStartDependencies, MlPluginStart>
) {
const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices);
uiActions.registerAction(editSwimlanePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id);
}
declare module '../../../../../src/plugins/ui_actions/public' {
export interface ActionContextMapping {
[EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext;
}
}