Revert "[ML] Embeddable Anomaly Swimlane (#64056)"
This reverts commit f62df99ae3
.
This commit is contained in:
parent
e3b9b946a9
commit
dccb1dcca1
|
@ -13,9 +13,7 @@
|
|||
"home",
|
||||
"licensing",
|
||||
"usageCollection",
|
||||
"share",
|
||||
"embeddable",
|
||||
"uiActions"
|
||||
"share"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"security",
|
||||
|
|
|
@ -4,15 +4,56 @@
|
|||
* 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 TooltipTrigger from 'react-popper-tooltip';
|
||||
import React, { useRef, FC } from 'react';
|
||||
import { TooltipValueFormatter } from '@elastic/charts';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
|
||||
import './_index.scss';
|
||||
import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service';
|
||||
|
||||
import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types';
|
||||
import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service';
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => {
|
||||
if (!headerData) {
|
||||
|
@ -22,101 +63,48 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo
|
|||
return formatter ? formatter(headerData) : headerData.label;
|
||||
};
|
||||
|
||||
const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => {
|
||||
const [tooltipData, setData] = useState<TooltipData>([]);
|
||||
const refCallback = useRef<ChildrenArg['triggerRef']>();
|
||||
export const ChartTooltip: FC = () => {
|
||||
const chartTooltipState = useObservable(chartTooltip$);
|
||||
const chartTooltipElement = useRefWithCallback(chartTooltipState);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = service.tooltipState$.subscribe(tooltipState => {
|
||||
if (refCallback.current) {
|
||||
// update trigger
|
||||
refCallback.current(tooltipState.target);
|
||||
}
|
||||
setData(tooltipState.tooltipData);
|
||||
});
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) {
|
||||
return <div className="mlChartTooltip mlChartTooltip--hidden" ref={chartTooltipElement} />;
|
||||
}
|
||||
|
||||
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 { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState;
|
||||
const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`;
|
||||
|
||||
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>
|
||||
)}
|
||||
return (
|
||||
<div className="mlChartTooltip" style={{ transform }} ref={chartTooltipElement}>
|
||||
{tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && (
|
||||
<div className="mlChartTooltip__header">
|
||||
{renderHeader(tooltipData[0], tooltipHeaderFormatter)}
|
||||
</div>
|
||||
);
|
||||
}) as TooltipTriggerProps['tooltip'],
|
||||
[tooltipData]
|
||||
);
|
||||
|
||||
const isTooltipShown = tooltipData.length > 0;
|
||||
|
||||
return (
|
||||
<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)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
42
x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts
vendored
Normal file
42
x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts
vendored
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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;
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
};
|
|
@ -4,61 +4,18 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChartTooltipService,
|
||||
getChartTooltipDefaultState,
|
||||
TooltipData,
|
||||
} from './chart_tooltip_service';
|
||||
import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service';
|
||||
|
||||
describe('ChartTooltipService', () => {
|
||||
let service: ChartTooltipService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ChartTooltipService();
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
it('should fail silently when target is not defined', () => {
|
||||
expect(() => {
|
||||
mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null);
|
||||
}).not.toThrow('Call to show() should fail silently.');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,73 +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, 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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -4,5 +4,5 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { ChartTooltipService } from './chart_tooltip_service';
|
||||
export { MlTooltipComponent } from './chart_tooltip';
|
||||
export { mlChartTooltipService } from './chart_tooltip_service';
|
||||
export { ChartTooltip } from './chart_tooltip';
|
||||
|
|
|
@ -4,23 +4,45 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { 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';
|
||||
import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout';
|
||||
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
|
||||
// @ts-ignore
|
||||
import { NewSelectionIdBadges } from './new_selection_id_badges/index';
|
||||
import {
|
||||
getGroupsFromJobs,
|
||||
getTimeRangeFromSelection,
|
||||
normalizeTimes,
|
||||
} from './job_select_service_utils';
|
||||
|
||||
interface GroupObj {
|
||||
groupId: string;
|
||||
jobIds: string[];
|
||||
}
|
||||
|
||||
function mergeSelection(
|
||||
jobIds: string[],
|
||||
groupObjs: GroupObj[],
|
||||
|
@ -49,7 +71,7 @@ function mergeSelection(
|
|||
}
|
||||
|
||||
type GroupsMap = Dictionary<string[]>;
|
||||
export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
|
||||
function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap {
|
||||
const map: GroupsMap = {};
|
||||
|
||||
if (selectedGroups.length) {
|
||||
|
@ -61,38 +83,81 @@ export 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 [maps, setMaps] = useState<JobSelectionMaps>({
|
||||
groupsMap: getInitialGroupsMap(selectedGroups),
|
||||
jobsMap: {},
|
||||
});
|
||||
const [jobs, setJobs] = useState<MlJobWithTimeRange[]>([]);
|
||||
const [groups, setGroups] = useState<any[]>([]);
|
||||
const [maps, setMaps] = useState({ 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);
|
||||
}
|
||||
|
@ -103,26 +168,78 @@ 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.',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({
|
||||
newSelection,
|
||||
jobIds,
|
||||
groups: newGroups,
|
||||
time,
|
||||
}) => {
|
||||
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));
|
||||
|
||||
setSelectedIds(newSelection);
|
||||
setNewSelection([]);
|
||||
|
||||
closeFlyout();
|
||||
|
||||
const time = applyTimeRange
|
||||
? getTimeRangeFromSelection(jobs, allNewSelectionUnique)
|
||||
: undefined;
|
||||
|
||||
setGlobalState({
|
||||
ml: {
|
||||
jobIds,
|
||||
groups: newGroups,
|
||||
jobIds: allNewSelectionUnique,
|
||||
groups: groupSelection,
|
||||
},
|
||||
...(time !== undefined ? { time } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
closeFlyout();
|
||||
};
|
||||
function toggleTimerangeSwitch() {
|
||||
setApplyTimeRange(!applyTimeRange);
|
||||
}
|
||||
|
||||
function removeId(id: string) {
|
||||
setNewSelection(newSelection.filter(item => item !== id));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setNewSelection([]);
|
||||
}
|
||||
|
||||
function renderJobSelectionBar() {
|
||||
return (
|
||||
|
@ -163,16 +280,103 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
|
|||
function renderFlyout() {
|
||||
if (isFlyoutVisible) {
|
||||
return (
|
||||
<JobSelectorFlyout
|
||||
dateFormatTz={dateFormatTz}
|
||||
timeseriesOnly={timeseriesOnly}
|
||||
singleSelection={singleSelection}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionConfirmed={applySelection}
|
||||
onJobsFetched={setMaps}
|
||||
onFlyoutClose={closeFlyout}
|
||||
maps={maps}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -184,3 +388,9 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
JobSelector.propTypes = {
|
||||
selectedJobIds: PropTypes.array,
|
||||
singleSelection: PropTypes.bool,
|
||||
timeseriesOnly: PropTypes.bool,
|
||||
};
|
||||
|
|
|
@ -4,32 +4,18 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { EuiBadge, EuiBadgeProps } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import { tabColor } from '../../../../../common/util/group_color_utils';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
interface JobSelectorBadgeProps {
|
||||
icon?: boolean;
|
||||
id: string;
|
||||
isGroup?: boolean;
|
||||
numJobs?: number;
|
||||
removeId?: Function;
|
||||
}
|
||||
|
||||
export const JobSelectorBadge: FC<JobSelectorBadgeProps> = ({
|
||||
icon,
|
||||
id,
|
||||
isGroup = false,
|
||||
numJobs,
|
||||
removeId,
|
||||
}) => {
|
||||
export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) {
|
||||
const color = isGroup ? tabColor(id) : 'hollow';
|
||||
let props = { color } as EuiBadgeProps;
|
||||
let props = { color };
|
||||
let jobCount;
|
||||
|
||||
if (icon === true && removeId) {
|
||||
// @ts-ignore
|
||||
if (icon === true) {
|
||||
props = {
|
||||
...props,
|
||||
iconType: 'cross',
|
||||
|
@ -51,4 +37,11 @@ export const JobSelectorBadge: FC<JobSelectorBadgeProps> = ({
|
|||
{`${id}${jobCount ? jobCount : ''}`}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
JobSelectorBadge.propTypes = {
|
||||
icon: PropTypes.bool,
|
||||
id: PropTypes.string.isRequired,
|
||||
isGroup: PropTypes.bool,
|
||||
numJobs: PropTypes.number,
|
||||
removeId: PropTypes.func,
|
||||
};
|
|
@ -1,289 +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 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>
|
||||
);
|
||||
};
|
|
@ -224,7 +224,7 @@ export function JobSelectorTable({
|
|||
<Fragment>
|
||||
{jobs.length === 0 && <EuiLoadingSpinner size="l" />}
|
||||
{jobs.length !== 0 && singleSelection === true && renderJobsTable()}
|
||||
{jobs.length !== 0 && !singleSelection && renderTabs()}
|
||||
{jobs.length !== 0 && singleSelection === undefined && renderTabs()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,29 +4,20 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC, MouseEventHandler } from 'react';
|
||||
import React from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { JobSelectorBadge } from '../job_selector_badge';
|
||||
import { JobSelectionMaps } from '../job_selector';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
interface NewSelectionIdBadgesProps {
|
||||
limit: number;
|
||||
maps: JobSelectionMaps;
|
||||
newSelection: string[];
|
||||
onDeleteClick?: Function;
|
||||
onLinkClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
showAllBadges?: boolean;
|
||||
}
|
||||
|
||||
export const NewSelectionIdBadges: FC<NewSelectionIdBadgesProps> = ({
|
||||
export function NewSelectionIdBadges({
|
||||
limit,
|
||||
maps,
|
||||
newSelection,
|
||||
onDeleteClick,
|
||||
onLinkClick,
|
||||
showAllBadges,
|
||||
}) => {
|
||||
}) {
|
||||
const badges = [];
|
||||
|
||||
for (let i = 0; i < newSelection.length; i++) {
|
||||
|
@ -69,5 +60,16 @@ export const NewSelectionIdBadges: FC<NewSelectionIdBadgesProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
return <>{badges}</>;
|
||||
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,
|
||||
};
|
|
@ -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 { getTimeBucketsFromCache } from '../../util/time_buckets';
|
||||
import { TimeBuckets } 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 = getTimeBucketsFromCache();
|
||||
const buckets = new TimeBuckets();
|
||||
|
||||
const tf = timefilter as any;
|
||||
let earliest: number | undefined;
|
||||
|
|
|
@ -106,6 +106,164 @@
|
|||
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;
|
||||
|
@ -170,161 +328,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,8 +36,9 @@ import {
|
|||
ExplorerNoJobsFound,
|
||||
ExplorerNoResultsFound,
|
||||
} from './components';
|
||||
import { ChartTooltip } from '../components/chart_tooltip';
|
||||
import { ExplorerSwimlane } from './explorer_swimlane';
|
||||
import { getTimeBucketsFromCache } from '../util/time_buckets';
|
||||
import { TimeBuckets } from '../util/time_buckets';
|
||||
import { InfluencersList } from '../components/influencers_list';
|
||||
import {
|
||||
ALLOW_CELL_RANGE_SELECTION,
|
||||
|
@ -80,7 +81,6 @@ 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,8 +179,6 @@ 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() {
|
||||
|
@ -360,6 +358,9 @@ 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
|
||||
|
@ -417,22 +418,17 @@ export class Explorer extends React.Component {
|
|||
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
|
||||
>
|
||||
{showOverallSwimlane && (
|
||||
<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>
|
||||
<ExplorerSwimlane
|
||||
chartWidth={swimlaneContainerWidth}
|
||||
filterActive={filterActive}
|
||||
maskAll={maskAll}
|
||||
TimeBuckets={TimeBuckets}
|
||||
swimlaneCellClick={this.swimlaneCellClick}
|
||||
swimlaneData={overallSwimlaneData}
|
||||
swimlaneType={SWIMLANE_TYPE.OVERALL}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
@ -498,22 +494,17 @@ export class Explorer extends React.Component {
|
|||
onMouseLeave={this.onSwimlaneLeaveHandler}
|
||||
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
|
||||
>
|
||||
<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>
|
||||
<ExplorerSwimlane
|
||||
chartWidth={swimlaneContainerWidth}
|
||||
filterActive={filterActive}
|
||||
maskAll={maskAll}
|
||||
TimeBuckets={TimeBuckets}
|
||||
swimlaneCellClick={this.swimlaneCellClick}
|
||||
swimlaneData={viewBySwimlaneData}
|
||||
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={this.swimlaneRenderDoneListener}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -29,8 +29,9 @@ import {
|
|||
removeLabelOverlap,
|
||||
} from '../../util/chart_utils';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
|
||||
import { getTimeBucketsFromCache } from '../../util/time_buckets';
|
||||
import { TimeBuckets } 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';
|
||||
|
||||
|
@ -49,7 +50,6 @@ 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, tooltipService } = this.props;
|
||||
const { tooManyBuckets } = 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 = getTimeBucketsFromCache();
|
||||
const timeBuckets = new TimeBuckets();
|
||||
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', () => tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all dots to new positions.
|
||||
dots
|
||||
|
@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
tooltipService.show(tooltipData, circle, {
|
||||
mlChartTooltipService.show(tooltipData, circle, {
|
||||
x: LINE_CHART_ANOMALY_RADIUS * 3,
|
||||
y: LINE_CHART_ANOMALY_RADIUS * 2,
|
||||
});
|
||||
|
|
|
@ -10,13 +10,11 @@ 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', () => ({
|
||||
getTimeBucketsFromCache: jest.fn(() => {
|
||||
return {
|
||||
setBounds: jest.fn(),
|
||||
setInterval: jest.fn(),
|
||||
getScaledDateFormat: jest.fn(),
|
||||
};
|
||||
}),
|
||||
TimeBuckets: function() {
|
||||
this.setBounds = jest.fn();
|
||||
this.setInterval = jest.fn();
|
||||
this.getScaledDateFormat = jest.fn();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../services/field_format_service', () => ({
|
||||
mlFieldFormatService: {
|
||||
|
@ -45,16 +43,8 @@ describe('ExplorerChart', () => {
|
|||
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
|
||||
|
||||
test('Initialize', () => {
|
||||
const mockTooltipService = {
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ExplorerChartDistribution
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
tooltipService={mockTooltipService}
|
||||
/>
|
||||
<ExplorerChartDistribution mlSelectSeverityService={mlSelectSeverityServiceMock} />
|
||||
);
|
||||
|
||||
// without setting any attributes and corresponding data
|
||||
|
@ -69,16 +59,10 @@ describe('ExplorerChart', () => {
|
|||
loading: true,
|
||||
};
|
||||
|
||||
const mockTooltipService = {
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ExplorerChartDistribution
|
||||
seriesConfig={config}
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
tooltipService={mockTooltipService}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -99,18 +83,12 @@ 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>
|
||||
);
|
||||
|
|
|
@ -38,9 +38,10 @@ import {
|
|||
showMultiBucketAnomalyTooltip,
|
||||
} from '../../util/chart_utils';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator';
|
||||
import { getTimeBucketsFromCache } from '../../util/time_buckets';
|
||||
import { TimeBuckets } 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';
|
||||
|
||||
|
@ -52,7 +53,6 @@ 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, tooltipService } = this.props;
|
||||
const { tooManyBuckets } = 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 = getTimeBucketsFromCache();
|
||||
const timeBuckets = new TimeBuckets();
|
||||
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', () => tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.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', () => tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Add rectangular markers for any scheduled events.
|
||||
const scheduledEventMarkers = lineChartGroup
|
||||
|
@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
tooltipService.show(tooltipData, circle, {
|
||||
mlChartTooltipService.show(tooltipData, circle, {
|
||||
x: LINE_CHART_ANOMALY_RADIUS * 3,
|
||||
y: LINE_CHART_ANOMALY_RADIUS * 2,
|
||||
});
|
||||
|
|
|
@ -10,13 +10,11 @@ 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', () => ({
|
||||
getTimeBucketsFromCache: jest.fn(() => {
|
||||
return {
|
||||
setBounds: jest.fn(),
|
||||
setInterval: jest.fn(),
|
||||
getScaledDateFormat: jest.fn(),
|
||||
};
|
||||
}),
|
||||
TimeBuckets: function() {
|
||||
this.setBounds = jest.fn();
|
||||
this.setInterval = jest.fn();
|
||||
this.getScaledDateFormat = jest.fn();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../services/field_format_service', () => ({
|
||||
mlFieldFormatService: {
|
||||
|
@ -45,16 +43,8 @@ describe('ExplorerChart', () => {
|
|||
afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox));
|
||||
|
||||
test('Initialize', () => {
|
||||
const mockTooltipService = {
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ExplorerChartSingleMetric
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
tooltipService={mockTooltipService}
|
||||
/>
|
||||
<ExplorerChartSingleMetric mlSelectSeverityService={mlSelectSeverityServiceMock} />
|
||||
);
|
||||
|
||||
// without setting any attributes and corresponding data
|
||||
|
@ -69,16 +59,10 @@ describe('ExplorerChart', () => {
|
|||
loading: true,
|
||||
};
|
||||
|
||||
const mockTooltipService = {
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ExplorerChartSingleMetric
|
||||
seriesConfig={config}
|
||||
mlSelectSeverityService={mlSelectSeverityServiceMock}
|
||||
tooltipService={mockTooltipService}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -99,18 +83,12 @@ 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>
|
||||
);
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import $ from 'jquery';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
|
@ -27,7 +29,6 @@ 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:
|
||||
|
@ -120,29 +121,19 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel })
|
|||
chartType === CHART_TYPE.POPULATION_DISTRIBUTION
|
||||
) {
|
||||
return (
|
||||
<MlTooltipComponent>
|
||||
{tooltipService => (
|
||||
<ExplorerChartDistribution
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
severity={severity}
|
||||
tooltipService={tooltipService}
|
||||
/>
|
||||
)}
|
||||
</MlTooltipComponent>
|
||||
<ExplorerChartDistribution
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
severity={severity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MlTooltipComponent>
|
||||
{tooltipService => (
|
||||
<ExplorerChartSingleMetric
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
severity={severity}
|
||||
tooltipService={tooltipService}
|
||||
/>
|
||||
)}
|
||||
</MlTooltipComponent>
|
||||
<ExplorerChartSingleMetric
|
||||
tooManyBuckets={tooManyBuckets}
|
||||
seriesConfig={series}
|
||||
severity={severity}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</React.Fragment>
|
||||
|
@ -150,36 +141,48 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel })
|
|||
}
|
||||
|
||||
// Flex layout wrapper for all explorer charts
|
||||
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;
|
||||
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;">'
|
||||
);
|
||||
}
|
||||
|
||||
const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series));
|
||||
componentWillUnmount() {
|
||||
// Remove div for the tooltip.
|
||||
$('.ml-explorer-charts-tooltip').remove();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,13 +21,11 @@ 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', () => ({
|
||||
getTimeBucketsFromCache: jest.fn(() => {
|
||||
return {
|
||||
setBounds: jest.fn(),
|
||||
setInterval: jest.fn(),
|
||||
getScaledDateFormat: jest.fn(),
|
||||
};
|
||||
}),
|
||||
TimeBuckets: function() {
|
||||
this.setBounds = jest.fn();
|
||||
this.setInterval = jest.fn();
|
||||
this.getScaledDateFormat = jest.fn();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../services/field_format_service', () => ({
|
||||
mlFieldFormatService: {
|
||||
|
|
|
@ -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 enum SWIMLANE_TYPE {
|
||||
OVERALL = 'overall',
|
||||
VIEW_BY = 'viewBy',
|
||||
}
|
||||
export const SWIMLANE_TYPE = {
|
||||
OVERALL: 'overall',
|
||||
VIEW_BY: 'viewBy',
|
||||
};
|
||||
|
||||
export const CHART_TYPE = {
|
||||
EVENT_DISTRIBUTION: 'event_distribution',
|
||||
|
|
|
@ -18,16 +18,13 @@ import { DeepPartial } from '../../../common/types/common';
|
|||
|
||||
import { jobSelectionActionCreator } from './actions';
|
||||
import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service';
|
||||
import { DRAG_SELECT_ACTION, EXPLORER_ACTION } from './explorer_constants';
|
||||
import { 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<{
|
||||
action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION];
|
||||
elements?: any[];
|
||||
}>();
|
||||
export const dragSelect$ = new Subject();
|
||||
|
||||
type ExplorerAction = Action | Observable<ActionPayload>;
|
||||
export const explorerAction$ = new Subject<ExplorerAction>();
|
||||
|
|
|
@ -8,99 +8,73 @@
|
|||
* 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',
|
||||
};
|
||||
|
||||
interface NodeWithData extends Node {
|
||||
__clickData__: {
|
||||
time: number;
|
||||
bucketScore: number;
|
||||
laneLabel: string;
|
||||
swimlaneType: string;
|
||||
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 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: Subscription | null = null;
|
||||
|
||||
rootNode = React.createRef<HTMLDivElement>();
|
||||
dragSelectSubscriber = null;
|
||||
|
||||
componentDidMount() {
|
||||
// property for data comparison to be able to filter
|
||||
// consecutive click events with the same data.
|
||||
let previousSelectedData: any = null;
|
||||
let previousSelectedData = null;
|
||||
|
||||
// Listen for dragSelect events
|
||||
this.dragSelectSubscriber = dragSelect$.subscribe(({ action, elements = [] }) => {
|
||||
const element = d3.select(this.rootNode.current!.parentNode!);
|
||||
const element = d3.select(this.rootNode.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() as NodeWithData).__clickData__;
|
||||
const firstSelectedCell = d3.select(elements[0]).node().__clickData__;
|
||||
|
||||
if (
|
||||
typeof firstSelectedCell !== 'undefined' &&
|
||||
swimlaneType === firstSelectedCell.swimlaneType
|
||||
) {
|
||||
const selectedData: SelectedData = elements.reduce(
|
||||
const selectedData = elements.reduce(
|
||||
(d, e) => {
|
||||
const cell = (d3.select(e).node() as NodeWithData).__clickData__;
|
||||
const cell = d3.select(e).node().__clickData__;
|
||||
d.bucketScore = Math.max(d.bucketScore, cell.bucketScore);
|
||||
d.laneLabels.push(cell.laneLabel);
|
||||
d.times.push(cell.time);
|
||||
|
@ -136,7 +110,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
} else if (action === DRAG_SELECT_ACTION.DRAG_START) {
|
||||
previousSelectedData = null;
|
||||
this.cellMouseoverActive = false;
|
||||
this.props.tooltipService.hide();
|
||||
mlChartTooltipService.hide(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -151,12 +125,12 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
if (this.dragSelectSubscriber !== null) {
|
||||
this.dragSelectSubscriber.unsubscribe();
|
||||
}
|
||||
const element = d3.select(this.rootNode.current!);
|
||||
const element = d3.select(this.rootNode);
|
||||
element.html('');
|
||||
}
|
||||
|
||||
selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) {
|
||||
const { selection, swimlaneCellClick = () => {}, swimlaneData, swimlaneType } = this.props;
|
||||
selectCell(cellsToSelect, { laneLabels, bucketScore, times }) {
|
||||
const { selection, swimlaneCellClick, swimlaneData, swimlaneType } = this.props;
|
||||
|
||||
let triggerNewSelection = false;
|
||||
|
||||
|
@ -199,7 +173,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
swimlaneCellClick(selectedCells);
|
||||
}
|
||||
|
||||
highlightOverall(times: number[]) {
|
||||
highlightOverall(times) {
|
||||
const overallSwimlane = d3.select('.ml-swimlane-overall');
|
||||
times.forEach(time => {
|
||||
const overallCell = overallSwimlane
|
||||
|
@ -209,7 +183,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
});
|
||||
}
|
||||
|
||||
highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) {
|
||||
highlightSelection(cellsToSelect, laneLabels, times) {
|
||||
const { swimlaneType } = this.props;
|
||||
|
||||
// This selects both overall and viewby swimlane
|
||||
|
@ -230,8 +204,8 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
.classed('sl-cell-inner-masked', false)
|
||||
.classed('sl-cell-inner-selected', true);
|
||||
|
||||
const rootParent = d3.select(this.rootNode.current!.parentNode!);
|
||||
rootParent.selectAll('.lane-label').classed('lane-label-masked', function(this: HTMLElement) {
|
||||
const rootParent = d3.select(this.rootNode.parentNode);
|
||||
rootParent.selectAll('.lane-label').classed('lane-label-masked', function() {
|
||||
return laneLabels.indexOf(d3.select(this).text()) === -1;
|
||||
});
|
||||
|
||||
|
@ -241,7 +215,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
}
|
||||
}
|
||||
|
||||
maskIrrelevantSwimlanes(maskAll: boolean) {
|
||||
maskIrrelevantSwimlanes(maskAll) {
|
||||
if (maskAll === true) {
|
||||
// This selects both overall and viewby swimlane
|
||||
const allSwimlanes = d3.selectAll('.ml-explorer-swimlane');
|
||||
|
@ -274,7 +248,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
}
|
||||
|
||||
renderSwimlane() {
|
||||
const element = d3.select(this.rootNode.current!.parentNode!);
|
||||
const element = d3.select(this.rootNode.parentNode);
|
||||
|
||||
// Consider the setting to support to select a range of cells
|
||||
if (!ALLOW_CELL_RANGE_SELECTION) {
|
||||
|
@ -289,7 +263,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
chartWidth,
|
||||
filterActive,
|
||||
maskAll,
|
||||
timeBuckets,
|
||||
TimeBuckets,
|
||||
swimlaneCellClick,
|
||||
swimlaneData,
|
||||
swimlaneType,
|
||||
|
@ -304,60 +278,11 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
points,
|
||||
} = swimlaneData;
|
||||
|
||||
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 {
|
||||
function colorScore(value) {
|
||||
return getSeverityColor(value);
|
||||
}
|
||||
|
||||
const numBuckets = Math.round((endTime - startTime) / stepSecs);
|
||||
const numBuckets = parseInt((endTime - startTime) / stepSecs);
|
||||
const cellHeight = 30;
|
||||
const height = (lanes.length + 1) * cellHeight - 10;
|
||||
const laneLabelWidth = 170;
|
||||
|
@ -375,13 +300,14 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
.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: number, i: number) {
|
||||
function cellMouseOverFactory(time, i) {
|
||||
// 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(this: HTMLElement, lane: string) {
|
||||
return function(lane) {
|
||||
const bucketScore = getBucketScore(lane, time);
|
||||
if (bucketScore !== 0) {
|
||||
lane = lane === '' ? EMPTY_FIELD_VALUE_LABEL : lane;
|
||||
|
@ -390,9 +316,49 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
};
|
||||
}
|
||||
|
||||
const cellMouseleave = () => {
|
||||
this.props.tooltipService.hide();
|
||||
};
|
||||
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 d3Lanes = swimlanes.selectAll('.lane').data(lanes);
|
||||
const d3LanesEnter = d3Lanes
|
||||
|
@ -400,13 +366,11 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
.append('div')
|
||||
.classed('lane', true);
|
||||
|
||||
const that = this;
|
||||
|
||||
d3LanesEnter
|
||||
.append('div')
|
||||
.classed('lane-label', true)
|
||||
.style('width', `${laneLabelWidth}px`)
|
||||
.html((label: string) => {
|
||||
.html(label => {
|
||||
const showFilterContext = filterActive === true && label === 'Overall';
|
||||
if (showFilterContext) {
|
||||
return i18n.translate('xpack.ml.explorer.overallSwimlaneUnfilteredLabel', {
|
||||
|
@ -418,21 +382,20 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
}
|
||||
})
|
||||
.on('click', () => {
|
||||
if (selection && typeof selection.lanes !== 'undefined' && swimlaneCellClick) {
|
||||
if (selection && typeof selection.lanes !== 'undefined') {
|
||||
swimlaneCellClick({});
|
||||
}
|
||||
})
|
||||
.each(function(this: HTMLElement) {
|
||||
.each(function() {
|
||||
if (swimlaneData.fieldName !== undefined) {
|
||||
d3.select(this)
|
||||
.on('mouseover', value => {
|
||||
that.props.tooltipService.show(
|
||||
mlChartTooltipService.show(
|
||||
[
|
||||
{ skipHeader: true } as ChartTooltipValue,
|
||||
{ skipHeader: true },
|
||||
{
|
||||
label: swimlaneData.fieldName!,
|
||||
label: swimlaneData.fieldName,
|
||||
value: value === '' ? EMPTY_FIELD_VALUE_LABEL : value,
|
||||
// @ts-ignore
|
||||
seriesIdentifier: { key: value },
|
||||
valueAccessor: 'fieldName',
|
||||
},
|
||||
|
@ -445,18 +408,15 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
);
|
||||
})
|
||||
.on('mouseout', () => {
|
||||
that.props.tooltipService.hide();
|
||||
mlChartTooltipService.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: string, time: number): number {
|
||||
function getBucketScore(lane, time) {
|
||||
let bucketScore = 0;
|
||||
const point = points.find(p => {
|
||||
return p.value > 0 && p.laneLabel === lane && p.time === time;
|
||||
|
@ -476,16 +436,16 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
.append('div')
|
||||
.classed('sl-cell', true)
|
||||
.style('width', `${cellWidth}px`)
|
||||
.attr('data-lane-label', (label: string) => mlEscape(label))
|
||||
.attr('data-lane-label', label => mlEscape(label))
|
||||
.attr('data-time', time)
|
||||
.attr('data-bucket-score', (lane: string) => {
|
||||
.attr('data-bucket-score', lane => {
|
||||
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(this: NodeWithData, laneLabel: string) {
|
||||
.each(function(laneLabel) {
|
||||
this.__clickData__ = {
|
||||
bucketScore: getBucketScore(laneLabel, time),
|
||||
laneLabel,
|
||||
|
@ -495,7 +455,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
});
|
||||
|
||||
// calls itself with each() to get access to lane (= d3 data)
|
||||
cell.append('div').each(function(this: HTMLElement, lane: string) {
|
||||
cell.append('div').each(function(lane) {
|
||||
const el = d3.select(this);
|
||||
|
||||
let color = 'none';
|
||||
|
@ -545,10 +505,13 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
|
||||
// remove overlapping labels
|
||||
let overlapCheck = 0;
|
||||
gAxis.selectAll('g.tick').each(function(this: HTMLElement) {
|
||||
gAxis.selectAll('g.tick').each(function() {
|
||||
const tick = d3.select(this);
|
||||
const xTransform = d3.transform(tick.attr('transform')).translate[0];
|
||||
const tickWidth = (tick.select('text').node() as SVGGraphicsElement).getBBox().width;
|
||||
const tickWidth = tick
|
||||
.select('text')
|
||||
.node()
|
||||
.getBBox().width;
|
||||
const xMinOffset = xTransform - tickWidth / 2;
|
||||
const xMaxOffset = xTransform + tickWidth / 2;
|
||||
// if the tick label overlaps the previous label
|
||||
|
@ -578,9 +541,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true);
|
||||
}
|
||||
|
||||
if (this.props.swimlaneRenderDoneListener) {
|
||||
this.props.swimlaneRenderDoneListener();
|
||||
}
|
||||
this.props.swimlaneRenderDoneListener();
|
||||
|
||||
if (
|
||||
(swimlaneType !== selectedType ||
|
||||
|
@ -592,7 +553,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
return;
|
||||
}
|
||||
|
||||
const cellsToSelect: Node[] = [];
|
||||
const cellsToSelect = [];
|
||||
const selectedLanes = _.get(selectionState, 'lanes', []);
|
||||
const selectedTimes = _.get(selectionState, 'times', []);
|
||||
const selectedTimeExtent = d3.extent(selectedTimes);
|
||||
|
@ -609,9 +570,9 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
`div[data-lane-label="${mlEscape(selectedLane)}"]`
|
||||
);
|
||||
|
||||
laneCells.each(function(this: HTMLElement) {
|
||||
laneCells.each(function() {
|
||||
const cell = d3.select(this);
|
||||
const cellTime = parseInt(cell.attr('data-time'), 10);
|
||||
const cellTime = cell.attr('data-time');
|
||||
if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) {
|
||||
cellsToSelect.push(cell.node());
|
||||
}
|
||||
|
@ -624,7 +585,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
}, 0);
|
||||
|
||||
const selectedCellTimes = cellsToSelect.map(e => {
|
||||
return (d3.select(e).node() as NodeWithData).__clickData__.time;
|
||||
return d3.select(e).node().__clickData__.time;
|
||||
});
|
||||
|
||||
if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) {
|
||||
|
@ -633,7 +594,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
if (selectedCellTimes.length > 0) {
|
||||
this.highlightOverall(selectedCellTimes);
|
||||
}
|
||||
this.maskIrrelevantSwimlanes(!!maskAll);
|
||||
this.maskIrrelevantSwimlanes(maskAll);
|
||||
} else {
|
||||
this.clearSelection();
|
||||
}
|
||||
|
@ -643,9 +604,15 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
return true;
|
||||
}
|
||||
|
||||
setRef(componentNode) {
|
||||
this.rootNode = componentNode;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { swimlaneType } = this.props;
|
||||
|
||||
return <div className={`ml-swimlanes ml-swimlane-${swimlaneType}`} ref={this.rootNode} />;
|
||||
return (
|
||||
<div className={`ml-swimlanes ml-swimlane-${swimlaneType}`} ref={this.setRef.bind(this)} />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,9 +12,6 @@ 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$: {
|
||||
|
@ -25,39 +22,32 @@ jest.mock('./explorer_dashboard_service', () => ({
|
|||
}));
|
||||
|
||||
function getExplorerSwimlaneMocks() {
|
||||
const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData;
|
||||
|
||||
const timeBuckets = ({
|
||||
const TimeBucketsMethods = {
|
||||
setInterval: jest.fn(),
|
||||
getScaledDateFormat: jest.fn(),
|
||||
} as unknown) as InstanceType<typeof TimeBucketsClass>;
|
||||
};
|
||||
const TimeBuckets = jest.fn(() => TimeBucketsMethods);
|
||||
TimeBuckets.mockMethods = TimeBucketsMethods;
|
||||
|
||||
const tooltipService = ({
|
||||
show: jest.fn(),
|
||||
hide: jest.fn(),
|
||||
} as unknown) as ChartTooltipService;
|
||||
const swimlaneData = { laneLabels: [] };
|
||||
|
||||
return {
|
||||
timeBuckets,
|
||||
TimeBuckets,
|
||||
swimlaneData,
|
||||
tooltipService,
|
||||
};
|
||||
}
|
||||
|
||||
const mockChartWidth = 800;
|
||||
|
||||
describe('ExplorerSwimlane', () => {
|
||||
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 } as DOMRect;
|
||||
// @ts-ignore
|
||||
const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 };
|
||||
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;
|
||||
});
|
||||
|
||||
|
@ -68,12 +58,11 @@ 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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -83,14 +72,12 @@ 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);
|
||||
// @ts-ignore
|
||||
expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
// @ts-ignore
|
||||
expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mocks.TimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
mocks.TimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
|
@ -101,26 +88,23 @@ 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);
|
||||
// @ts-ignore
|
||||
expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
// @ts-ignore
|
||||
expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mocks.TimeBuckets.mockMethods.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
mocks.TimeBuckets.mockMethods.getScaledDateFormat.mock.calls.length
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
|
@ -17,16 +17,10 @@ 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: SwimlanePoint[];
|
||||
points: any[];
|
||||
interval: number;
|
||||
}
|
||||
|
||||
|
|
|
@ -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, getTimeBucketsFromCache } from '../util/time_buckets';
|
||||
import { getBoundsRoundedToInterval, TimeBuckets } 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 = getTimeBucketsFromCache();
|
||||
const buckets = new TimeBuckets();
|
||||
buckets.setInterval('auto');
|
||||
buckets.setBounds(bounds);
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isMultiMetricJobCreator,
|
||||
isPopulationJobCreator,
|
||||
} from '../../../../common/job_creator';
|
||||
import { getTimeBucketsFromCache, TimeBuckets } from '../../../../../../util/time_buckets';
|
||||
import { 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 = getTimeBucketsFromCache();
|
||||
const interval = new TimeBuckets();
|
||||
interval.setInterval('auto');
|
||||
interval.setBounds(chartInterval.getBounds());
|
||||
cs.intervalMs = interval.getInterval().asMilliseconds();
|
||||
|
|
|
@ -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 { getTimeBucketsFromCache } from '../../../../util/time_buckets';
|
||||
import { TimeBuckets } 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 = getTimeBucketsFromCache();
|
||||
const chartInterval = new TimeBuckets();
|
||||
chartInterval.setBarTarget(BAR_TARGET);
|
||||
chartInterval.setMaxBars(MAX_BARS);
|
||||
chartInterval.setInterval('auto');
|
||||
|
|
|
@ -1,58 +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 { 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);
|
||||
}
|
||||
}
|
|
@ -1,308 +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 { 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;
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { HttpFetchOptionsWithPath, HttpFetchOptions, HttpStart } from 'kibana/public';
|
||||
import { HttpFetchOptionsWithPath, HttpFetchOptions } from 'kibana/public';
|
||||
import { getHttp } from '../util/dependency_cache';
|
||||
|
||||
function getResultHeaders(headers: HeadersInit): HeadersInit {
|
||||
|
@ -102,105 +102,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,8 +46,6 @@ 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 }>;
|
||||
|
|
|
@ -20,16 +20,7 @@ export function getOverallBucketScores(
|
|||
latestMs: any,
|
||||
interval?: any
|
||||
): 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 getInfluencerValueMaxScoreByTime(): Promise<any>;
|
||||
export function getRecordInfluencers(): Promise<any>;
|
||||
export function getRecordsForInfluencer(): Promise<any>;
|
||||
export function getRecordsForDetector(): Promise<any>;
|
||||
|
|
|
@ -8,11 +8,9 @@ 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 {
|
||||
|
|
|
@ -33,12 +33,13 @@ import {
|
|||
showMultiBucketAnomalyTooltip,
|
||||
} from '../../../util/chart_utils';
|
||||
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
|
||||
import { getTimeBucketsFromCache } from '../../../util/time_buckets';
|
||||
import { TimeBuckets } 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,
|
||||
|
@ -112,7 +113,6 @@ class TimeseriesChartIntl extends Component {
|
|||
zoomTo: PropTypes.object,
|
||||
zoomFromFocusLoaded: PropTypes.object,
|
||||
zoomToFocusLoaded: PropTypes.object,
|
||||
tooltipService: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
rowMouseenterSubscriber = null;
|
||||
|
@ -582,8 +582,6 @@ 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.
|
||||
|
@ -693,7 +691,7 @@ class TimeseriesChartIntl extends Component {
|
|||
}
|
||||
|
||||
// Get the scaled date format to use for x axis tick labels.
|
||||
const timeBuckets = getTimeBucketsFromCache();
|
||||
const timeBuckets = new TimeBuckets();
|
||||
timeBuckets.setInterval('auto');
|
||||
timeBuckets.setBounds(bounds);
|
||||
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
|
||||
|
@ -721,8 +719,7 @@ class TimeseriesChartIntl extends Component {
|
|||
focusChartHeight,
|
||||
this.focusXScale,
|
||||
showAnnotations,
|
||||
showFocusChartTooltip,
|
||||
hideFocusChartTooltip
|
||||
showFocusChartTooltip
|
||||
);
|
||||
|
||||
// disable brushing (creation of annotations) when annotations aren't shown
|
||||
|
@ -756,7 +753,7 @@ class TimeseriesChartIntl extends Component {
|
|||
.on('mouseover', function(d) {
|
||||
showFocusChartTooltip(d, this);
|
||||
})
|
||||
.on('mouseout', () => this.props.tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all dots to new positions.
|
||||
dots
|
||||
|
@ -797,7 +794,7 @@ class TimeseriesChartIntl extends Component {
|
|||
.on('mouseover', function(d) {
|
||||
showFocusChartTooltip(d, this);
|
||||
})
|
||||
.on('mouseout', () => this.props.tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all markers to new positions.
|
||||
multiBucketMarkers
|
||||
|
@ -857,7 +854,7 @@ class TimeseriesChartIntl extends Component {
|
|||
.on('mouseover', function(d) {
|
||||
showFocusChartTooltip(d, this);
|
||||
})
|
||||
.on('mouseout', () => this.props.tooltipService.hide());
|
||||
.on('mouseout', () => mlChartTooltipService.hide());
|
||||
|
||||
// Update all dots to new positions.
|
||||
forecastDots
|
||||
|
@ -1022,7 +1019,7 @@ class TimeseriesChartIntl extends Component {
|
|||
.attr('y2', cxtChartHeight + swlHeight);
|
||||
|
||||
// Add x axis.
|
||||
const timeBuckets = getTimeBucketsFromCache();
|
||||
const timeBuckets = new TimeBuckets();
|
||||
timeBuckets.setInterval('auto');
|
||||
timeBuckets.setBounds(bounds);
|
||||
const xAxisTickFormat = timeBuckets.getScaledDateFormat();
|
||||
|
@ -1638,7 +1635,7 @@ class TimeseriesChartIntl extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
this.props.tooltipService.show(tooltipData, circle, {
|
||||
mlChartTooltipService.show(tooltipData, circle, {
|
||||
x: xOffset,
|
||||
y: 0,
|
||||
});
|
||||
|
@ -1716,7 +1713,7 @@ class TimeseriesChartIntl extends Component {
|
|||
d3.select('.focus-chart-markers')
|
||||
.selectAll('.anomaly-marker.highlighted')
|
||||
.remove();
|
||||
this.props.tooltipService.hide();
|
||||
mlChartTooltipService.hide();
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
|
|
|
@ -11,6 +11,9 @@ 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';
|
||||
|
@ -107,8 +110,7 @@ export function renderAnnotations(
|
|||
focusChartHeight: number,
|
||||
focusXScale: TimeseriesChart['focusXScale'],
|
||||
showAnnotations: boolean,
|
||||
showFocusChartTooltip: (d: Annotation, t: object) => {},
|
||||
hideFocusChartTooltip: () => void
|
||||
showFocusChartTooltip: (d: Annotation, t: object) => {}
|
||||
) {
|
||||
const upperRectMargin = ANNOTATION_UPPER_RECT_MARGIN;
|
||||
const upperTextMargin = ANNOTATION_UPPER_TEXT_MARGIN;
|
||||
|
@ -154,7 +156,7 @@ export function renderAnnotations(
|
|||
.on('mouseover', function(this: object, d: Annotation) {
|
||||
showFocusChartTooltip(d, this);
|
||||
})
|
||||
.on('mouseout', () => hideFocusChartTooltip())
|
||||
.on('mouseout', () => mlChartTooltipService.hide())
|
||||
.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
|
||||
|
|
|
@ -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 { MlTooltipComponent } from '../components/chart_tooltip';
|
||||
import { ChartTooltip } 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,6 +1204,9 @@ 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' }}>
|
||||
|
@ -1298,21 +1301,16 @@ export class TimeSeriesExplorer extends React.Component {
|
|||
)}
|
||||
</EuiFlexGroup>
|
||||
<div className="ml-timeseries-chart" data-test-subj="mlSingleMetricViewerChart">
|
||||
<MlTooltipComponent>
|
||||
{tooltipService => (
|
||||
<TimeseriesChart
|
||||
{...chartProps}
|
||||
bounds={bounds}
|
||||
detectorIndex={selectedDetectorIndex}
|
||||
renderFocusChartOnly={renderFocusChartOnly}
|
||||
selectedJob={selectedJob}
|
||||
showAnnotations={showAnnotations}
|
||||
showForecast={showForecast}
|
||||
showModelBounds={showModelBounds}
|
||||
tooltipService={tooltipService}
|
||||
/>
|
||||
)}
|
||||
</MlTooltipComponent>
|
||||
<TimeseriesChart
|
||||
{...chartProps}
|
||||
bounds={bounds}
|
||||
detectorIndex={selectedDetectorIndex}
|
||||
renderFocusChartOnly={renderFocusChartOnly}
|
||||
selectedJob={selectedJob}
|
||||
showAnnotations={showAnnotations}
|
||||
showForecast={showForecast}
|
||||
showModelBounds={showModelBounds}
|
||||
/>
|
||||
</div>
|
||||
{showAnnotations && focusAnnotationData.length > 0 && (
|
||||
<div>
|
||||
|
|
|
@ -16,7 +16,7 @@ import moment from 'moment-timezone';
|
|||
import { isTimeSeriesViewJob } from '../../../../common/util/job_utils';
|
||||
import { parseInterval } from '../../../../common/util/parse_interval';
|
||||
|
||||
import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../../util/time_buckets';
|
||||
import { TimeBuckets, getBoundsRoundedToInterval } 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 = getTimeBucketsFromCache();
|
||||
const buckets = new TimeBuckets();
|
||||
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 = getTimeBucketsFromCache();
|
||||
const buckets = new TimeBuckets();
|
||||
buckets.setInterval('auto');
|
||||
buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET));
|
||||
buckets.setMaxBars(maxBars);
|
||||
|
|
|
@ -1,7 +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.
|
||||
*/
|
||||
|
||||
export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number;
|
|
@ -13,7 +13,7 @@ export function formatHumanReadableDate(ts: number) {
|
|||
return formatDate(ts, 'MMMM Do YYYY');
|
||||
}
|
||||
|
||||
export function formatHumanReadableDateTime(ts: number): string {
|
||||
export function formatHumanReadableDateTime(ts: number) {
|
||||
return formatDate(ts, 'MMMM Do YYYY, HH:mm');
|
||||
}
|
||||
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
import { Moment } from 'moment';
|
||||
|
||||
export interface TimeRangeBounds {
|
||||
min?: Moment;
|
||||
max?: Moment;
|
||||
declare interface TimeRangeBounds {
|
||||
min: Moment | undefined;
|
||||
max: Moment | undefined;
|
||||
}
|
||||
|
||||
export declare interface TimeBucketsInterval {
|
||||
|
@ -17,28 +17,11 @@ export declare interface TimeBucketsInterval {
|
|||
expression: string;
|
||||
}
|
||||
|
||||
export interface TimeBucketsConfig {
|
||||
'histogram:maxBars': number;
|
||||
'histogram:barTarget': number;
|
||||
dateFormat: string;
|
||||
'dateFormat:scaled': 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 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>;
|
||||
|
|
|
@ -18,25 +18,15 @@ 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(timeBucketsConfig) {
|
||||
this._timeBucketsConfig = timeBucketsConfig;
|
||||
this.barTarget = this._timeBucketsConfig['histogram:barTarget'];
|
||||
this.maxBars = this._timeBucketsConfig['histogram:maxBars'];
|
||||
export function TimeBuckets() {
|
||||
const uiSettings = getUiSettings();
|
||||
this.barTarget = uiSettings.get('histogram:barTarget');
|
||||
this.maxBars = uiSettings.get('histogram:maxBars');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -307,8 +297,9 @@ TimeBuckets.prototype.getIntervalToNearestMultiple = function(divisorSecs) {
|
|||
* @return {string}
|
||||
*/
|
||||
TimeBuckets.prototype.getScaledDateFormat = function() {
|
||||
const uiSettings = getUiSettings();
|
||||
const interval = this.getInterval();
|
||||
const rules = this._timeBucketsConfig['dateFormat:scaled'];
|
||||
const rules = uiSettings.get('dateFormat:scaled');
|
||||
|
||||
for (let i = rules.length - 1; i >= 0; i--) {
|
||||
const rule = rules[i];
|
||||
|
@ -317,18 +308,19 @@ TimeBuckets.prototype.getScaledDateFormat = function() {
|
|||
}
|
||||
}
|
||||
|
||||
return this._timeBucketsConfig.dateFormat;
|
||||
return uiSettings.get('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
|
||||
this._timeBucketsConfig
|
||||
uiSettings.get
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,20 +7,34 @@
|
|||
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(() => {
|
||||
const timeBucketsConfig = {
|
||||
'histogram:maxBars': 100,
|
||||
'histogram:barTarget': 50,
|
||||
};
|
||||
|
||||
autoBuckets = new TimeBuckets(timeBucketsConfig);
|
||||
autoBuckets = new TimeBuckets();
|
||||
autoBuckets.setInterval('auto');
|
||||
|
||||
customBuckets = new TimeBuckets(timeBucketsConfig);
|
||||
customBuckets = new TimeBuckets();
|
||||
customBuckets.setInterval('auto');
|
||||
customBuckets.setBarTarget(500);
|
||||
customBuckets.setMaxBars(550);
|
||||
|
|
|
@ -1,115 +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 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();
|
||||
}
|
||||
}
|
|
@ -1,50 +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 { 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 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']);
|
||||
});
|
||||
});
|
|
@ -1,81 +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 { 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);
|
||||
}
|
||||
}
|
|
@ -1,201 +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 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>
|
||||
);
|
||||
};
|
|
@ -1,95 +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 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',
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
|
@ -1,124 +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 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();
|
||||
});
|
||||
});
|
|
@ -1,122 +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 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>
|
||||
);
|
||||
};
|
|
@ -1,8 +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.
|
||||
*/
|
||||
|
||||
export { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane_embeddable_factory';
|
||||
export { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from './anomaly_swimlane_embeddable';
|
|
@ -1,271 +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 { 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,211 +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 { 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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,23 +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 { 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
|
||||
);
|
||||
}
|
|
@ -12,7 +12,6 @@ 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';
|
||||
|
@ -20,9 +19,6 @@ 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;
|
||||
|
@ -35,8 +31,6 @@ export interface MlSetupDependencies {
|
|||
usageCollection: UsageCollectionSetup;
|
||||
licenseManagement?: LicenseManagementUIPluginSetup;
|
||||
home: HomePublicPluginSetup;
|
||||
embeddable: EmbeddableSetup;
|
||||
uiActions: UiActionsSetup;
|
||||
}
|
||||
|
||||
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||
|
@ -63,8 +57,6 @@ 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,
|
||||
|
@ -79,11 +71,6 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
|||
registerFeature(pluginsSetup.home);
|
||||
|
||||
initManagementSection(pluginsSetup, core);
|
||||
|
||||
registerMlUiActions(pluginsSetup.uiActions, core);
|
||||
|
||||
registerEmbeddables(pluginsSetup.embeddable, core);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,65 +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 { 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'
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,30 +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 { 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);
|
||||
}
|
||||
|
||||
declare module '../../../../../src/plugins/ui_actions/public' {
|
||||
export interface ActionContextMapping {
|
||||
[EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue