[ML] Anomaly Explorer swim lane pagination (#70063)

* [ML] use explorer service

* [ML] WIP pagination

* [ML] add to dashboard without the limit

* [ML] WIP

* [ML] loading states

* [ML] viewBySwimlaneDataLoading on field change

* [ML] fix dashboard control

* [ML] universal swim lane container, embeddable pagination

* [ML] fix css issue

* [ML] rename anomalyTimelineService

* [ML] rename callback

* [ML] rename container component

* [ML] empty state, increase pagination margin

* [ML] check for loading

* [ML] fix i18n

* [ML] fix unit test

* [ML] improve selected cells

* [ML] fix overall selection with changing job selection

* [ML] required props for pagination component

* [ML] move RESIZE_IGNORED_DIFF_PX

* [ML] jest tests

* [ML] add test subject

* [ML] SWIM_LANE_DEFAULT_PAGE_SIZE

* [ML] change empty state styling

* [ML] fix agg size for influencer filters

* [ML] remove debounce

* [ML] SCSS variables, rename swim lane class

* [ML] job selector using context

* [ML] set padding for embeddable panel

* [ML] adjust pagination styles

* [ML] replace custom time range subject with timefilter

* [ML] change loading indicator to mono

* [ML] use swim lane type constant

* [ML] change context naming

* [ML] update jest snapshot

* [ML] fix tests
This commit is contained in:
Dima Arnautov 2020-07-02 07:30:18 -07:00 committed by GitHub
parent 335c9bb148
commit 854e7a5204
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1367 additions and 1293 deletions

View file

@ -7,7 +7,7 @@
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreStart } from 'kibana/public';
import { AppMountParameters, CoreStart, HttpStart } from 'kibana/public';
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
@ -17,6 +17,8 @@ import { setLicenseCache } from './license';
import { MlSetupDependencies, MlStartDependencies } from '../plugin';
import { MlRouter } from './routing';
import { mlApiServicesProvider } from './services/ml_api_service';
import { HttpService } from './services/http_service';
type MlDependencies = MlSetupDependencies & MlStartDependencies;
@ -27,6 +29,23 @@ interface AppProps {
const localStorage = new Storage(window.localStorage);
/**
* Provides global services available across the entire ML app.
*/
export function getMlGlobalServices(httpStart: HttpStart) {
const httpService = new HttpService(httpStart);
return {
httpService,
mlApiServices: mlApiServicesProvider(httpService),
};
}
export interface MlServicesContext {
mlServices: MlGlobalServices;
}
export type MlGlobalServices = ReturnType<typeof getMlGlobalServices>;
const App: FC<AppProps> = ({ coreStart, deps }) => {
const pageDeps = {
indexPatterns: deps.data.indexPatterns,
@ -47,7 +66,9 @@ const App: FC<AppProps> = ({ coreStart, deps }) => {
const I18nContext = coreStart.i18n.Context;
return (
<I18nContext>
<KibanaContextProvider services={services}>
<KibanaContextProvider
services={{ ...services, mlServices: getMlGlobalServices(coreStart.http) }}
>
<MlRouter pageDeps={pageDeps} />
</KibanaContextProvider>
</I18nContext>

View file

@ -27,7 +27,6 @@ import {
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';
@ -66,7 +65,10 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
withTimeRangeSelector = true,
}) => {
const {
services: { notifications },
services: {
notifications,
mlServices: { mlApiServices },
},
} = useMlKibana();
const [newSelection, setNewSelection] = useState(selectedIds);
@ -151,7 +153,7 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
async function fetchJobs() {
try {
const resp = await ml.jobs.jobsWithTimerange(dateFormatTz);
const resp = await mlApiServices.jobs.jobsWithTimerange(dateFormatTz);
const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
setJobs(normalizedJobs);

View file

@ -9,10 +9,7 @@ import { Subscription } from 'rxjs';
import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui';
import { TimeRange, TimeHistoryContract } from 'src/plugins/data/public';
import {
mlTimefilterRefresh$,
mlTimefilterTimeChange$,
} from '../../../services/timefilter_refresh_service';
import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service';
import { useUrlState } from '../../../util/url_state';
import { useMlKibana } from '../../../contexts/kibana';
@ -108,7 +105,6 @@ export const DatePickerWrapper: FC = () => {
timefilter.setTime(newTime);
setTime(newTime);
setRecentlyUsedRanges(getRecentlyUsedRanges());
mlTimefilterTimeChange$.next({ lastRefresh: Date.now(), timeRange: { start, end } });
}
function updateInterval({

View file

@ -13,6 +13,7 @@ import {
import { SecurityPluginSetup } from '../../../../../security/public';
import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public';
import { SharePluginStart } from '../../../../../../../src/plugins/share/public';
import { MlServicesContext } from '../../app';
interface StartPlugins {
data: DataPublicPluginStart;
@ -20,7 +21,8 @@ interface StartPlugins {
licenseManagement?: LicenseManagementUIPluginSetup;
share: SharePluginStart;
}
export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string };
export type StartServices = CoreStart &
StartPlugins & { kibanaVersion: string } & MlServicesContext;
// eslint-disable-next-line react-hooks/rules-of-hooks
export const useMlKibana = () => useKibana<StartServices>();
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;

View file

@ -7,6 +7,7 @@
import React from 'react';
import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public';
import { SavedSearchSavedObject } from '../../../../common/types/kibana';
import { MlServicesContext } from '../../app';
export interface MlContextValue {
combinedQuery: any;
@ -34,4 +35,4 @@ export type SavedSearchQuery = object;
// Multiple custom hooks can be created to access subsets of
// the overall context value if necessary too,
// see useCurrentIndexPattern() for example.
export const MlContext = React.createContext<Partial<MlContextValue>>({});
export const MlContext = React.createContext<Partial<MlContextValue & MlServicesContext>>({});

View file

@ -1,3 +1,5 @@
$borderRadius: $euiBorderRadius / 2;
.ml-swimlane-selector {
visibility: hidden;
}
@ -104,10 +106,9 @@
// SASSTODO: This entire selector needs to be rewritten.
// It looks extremely brittle with very specific sizing units
.ml-explorer-swimlane {
.mlExplorerSwimlane {
user-select: none;
padding: 0;
margin-bottom: $euiSizeS;
line.gridLine {
stroke: $euiBorderColor;
@ -218,17 +219,20 @@
div.lane {
height: 30px;
border-bottom: 0px;
border-radius: 2px;
margin-top: -1px;
border-radius: $borderRadius;
white-space: nowrap;
&:not(:first-child) {
margin-top: -1px;
}
div.lane-label {
display: inline-block;
font-size: 13px;
font-size: $euiFontSizeXS;
height: 30px;
text-align: right;
vertical-align: middle;
border-radius: 2px;
border-radius: $borderRadius;
padding-right: 5px;
margin-right: 5px;
border: 1px solid transparent;
@ -261,7 +265,7 @@
.sl-cell-inner-dragselect {
height: 26px;
margin: 1px;
border-radius: 2px;
border-radius: $borderRadius;
text-align: center;
}
@ -293,7 +297,7 @@
.sl-cell-inner,
.sl-cell-inner-dragselect {
border: 2px solid $euiColorDarkShade;
border-radius: 2px;
border-radius: $borderRadius;
opacity: 1;
}
}

View file

@ -11,6 +11,7 @@ import useObservable from 'react-use/lib/useObservable';
import { forkJoin, of, Observable, Subject } from 'rxjs';
import { mergeMap, switchMap, tap } from 'rxjs/operators';
import { useCallback, useMemo } from 'react';
import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service';
import { explorerService } from '../explorer_dashboard_service';
import {
@ -22,15 +23,17 @@ import {
loadAnomaliesTableData,
loadDataForCharts,
loadFilteredTopInfluencers,
loadOverallData,
loadTopInfluencers,
loadViewBySwimlane,
loadViewByTopFieldValuesForSelectedTime,
AppStateSelectedCells,
ExplorerJob,
TimeRangeBounds,
} from '../explorer_utils';
import { ExplorerState } from '../reducers';
import { useMlKibana, useTimefilter } from '../../contexts/kibana';
import { AnomalyTimelineService } from '../../services/anomaly_timeline_service';
import { mlResultsServiceProvider } from '../../services/results_service';
import { isViewBySwimLaneData } from '../swimlane_container';
import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants';
// Memoize the data fetching methods.
// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument
@ -39,13 +42,13 @@ import { ExplorerState } from '../reducers';
// about this parameter. The generic type T retains and returns the type information of
// the original function.
const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs);
const wrapWithLastRefreshArg = <T extends (...a: any[]) => any>(func: T) => {
const wrapWithLastRefreshArg = <T extends (...a: any[]) => any>(func: T, context: any = null) => {
return function (lastRefresh: number, ...args: Parameters<T>): ReturnType<T> {
return func.apply(null, args);
return func.apply(context, args);
};
};
const memoize = <T extends (...a: any[]) => any>(func: T) => {
return memoizeOne(wrapWithLastRefreshArg<T>(func), memoizeIsEqual);
const memoize = <T extends (...a: any[]) => any>(func: T, context?: any) => {
return memoizeOne(wrapWithLastRefreshArg<T>(func, context), memoizeIsEqual);
};
const memoizedAnomalyDataChange = memoize<typeof anomalyDataChange>(anomalyDataChange);
@ -56,9 +59,7 @@ const memoizedLoadDataForCharts = memoize<typeof loadDataForCharts>(loadDataForC
const memoizedLoadFilteredTopInfluencers = memoize<typeof loadFilteredTopInfluencers>(
loadFilteredTopInfluencers
);
const memoizedLoadOverallData = memoize(loadOverallData);
const memoizedLoadTopInfluencers = memoize(loadTopInfluencers);
const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane);
const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData);
export interface LoadExplorerDataConfig {
@ -73,6 +74,9 @@ export interface LoadExplorerDataConfig {
tableInterval: string;
tableSeverity: number;
viewBySwimlaneFieldName: string;
viewByFromPage: number;
viewByPerPage: number;
swimlaneContainerWidth: number;
}
export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => {
@ -87,183 +91,213 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi
/**
* Fetches the data necessary for the Anomaly Explorer using observables.
*
* @param config LoadExplorerDataConfig
*
* @return Partial<ExplorerState>
*/
function loadExplorerData(config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> {
if (!isLoadExplorerDataConfig(config)) {
return of({});
}
const {
bounds,
lastRefresh,
influencersFilterQuery,
noInfluencersConfigured,
selectedCells,
selectedJobs,
swimlaneBucketInterval,
swimlaneLimit,
tableInterval,
tableSeverity,
viewBySwimlaneFieldName,
} = config;
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName);
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
const timerange = getSelectionTimeRange(
selectedCells,
swimlaneBucketInterval.asSeconds(),
bounds
const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService) => {
const memoizedLoadOverallData = memoize(
anomalyTimelineService.loadOverallData,
anomalyTimelineService
);
const memoizedLoadViewBySwimlane = memoize(
anomalyTimelineService.loadViewBySwimlane,
anomalyTimelineService
);
return (config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> => {
if (!isLoadExplorerDataConfig(config)) {
return of({});
}
const dateFormatTz = getDateFormatTz();
// First get the data where we have all necessary args at hand using forkJoin:
// annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues
return forkJoin({
annotationsData: memoizedLoadAnnotationsTableData(
const {
bounds,
lastRefresh,
influencersFilterQuery,
noInfluencersConfigured,
selectedCells,
selectedJobs,
swimlaneBucketInterval.asSeconds(),
bounds
),
anomalyChartRecords: memoizedLoadDataForCharts(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
selectionInfluencers,
selectedCells,
influencersFilterQuery
),
influencers:
selectionInfluencers.length === 0
? memoizedLoadTopInfluencers(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
[],
noInfluencersConfigured,
influencersFilterQuery
)
: Promise.resolve({}),
overallState: memoizedLoadOverallData(
lastRefresh,
selectedJobs,
swimlaneBucketInterval,
bounds
),
tableData: memoizedLoadAnomaliesTableData(
lastRefresh,
selectedCells,
selectedJobs,
dateFormatTz,
swimlaneBucketInterval.asSeconds(),
bounds,
viewBySwimlaneFieldName,
swimlaneLimit,
tableInterval,
tableSeverity,
influencersFilterQuery
),
topFieldValues:
selectedCells !== undefined && selectedCells.showTopFieldValues === true
? loadViewByTopFieldValuesForSelectedTime(
viewBySwimlaneFieldName,
swimlaneContainerWidth,
viewByFromPage,
viewByPerPage,
} = config;
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName);
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
const timerange = getSelectionTimeRange(
selectedCells,
swimlaneBucketInterval.asSeconds(),
bounds
);
const dateFormatTz = getDateFormatTz();
// First get the data where we have all necessary args at hand using forkJoin:
// annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues
return forkJoin({
annotationsData: memoizedLoadAnnotationsTableData(
lastRefresh,
selectedCells,
selectedJobs,
swimlaneBucketInterval.asSeconds(),
bounds
),
anomalyChartRecords: memoizedLoadDataForCharts(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
selectionInfluencers,
selectedCells,
influencersFilterQuery
),
influencers:
selectionInfluencers.length === 0
? memoizedLoadTopInfluencers(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
[],
noInfluencersConfigured,
influencersFilterQuery
)
: Promise.resolve({}),
overallState: memoizedLoadOverallData(lastRefresh, selectedJobs, swimlaneContainerWidth),
tableData: memoizedLoadAnomaliesTableData(
lastRefresh,
selectedCells,
selectedJobs,
dateFormatTz,
swimlaneBucketInterval.asSeconds(),
bounds,
viewBySwimlaneFieldName,
tableInterval,
tableSeverity,
influencersFilterQuery
),
topFieldValues:
selectedCells !== undefined && selectedCells.showTopFieldValues === true
? anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime(
timerange.earliestMs,
timerange.latestMs,
selectedJobs,
viewBySwimlaneFieldName,
swimlaneLimit,
viewByPerPage,
viewByFromPage,
swimlaneContainerWidth
)
: Promise.resolve([]),
}).pipe(
// Trigger a side-effect action to reset view-by swimlane,
// show the view-by loading indicator
// and pass on the data we already fetched.
tap(explorerService.setViewBySwimlaneLoading),
// Trigger a side-effect to update the charts.
tap(({ anomalyChartRecords }) => {
if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) {
memoizedAnomalyDataChange(
lastRefresh,
anomalyChartRecords,
timerange.earliestMs,
timerange.latestMs,
selectedJobs,
viewBySwimlaneFieldName,
swimlaneLimit,
noInfluencersConfigured
)
: Promise.resolve([]),
}).pipe(
// Trigger a side-effect action to reset view-by swimlane,
// show the view-by loading indicator
// and pass on the data we already fetched.
tap(explorerService.setViewBySwimlaneLoading),
// Trigger a side-effect to update the charts.
tap(({ anomalyChartRecords }) => {
if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) {
memoizedAnomalyDataChange(
lastRefresh,
anomalyChartRecords,
timerange.earliestMs,
timerange.latestMs,
tableSeverity
);
} else {
memoizedAnomalyDataChange(
lastRefresh,
[],
timerange.earliestMs,
timerange.latestMs,
tableSeverity
);
}
}),
// Load view-by swimlane data and filtered top influencers.
// mergeMap is used to have access to the already fetched data and act on it in arg #1.
// In arg #2 of mergeMap we combine the data and pass it on in the action format
// which can be consumed by explorerReducer() later on.
mergeMap(
({ anomalyChartRecords, influencers, overallState, topFieldValues }) =>
forkJoin({
influencers:
(selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
anomalyChartRecords !== undefined &&
anomalyChartRecords.length > 0
? memoizedLoadFilteredTopInfluencers(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
anomalyChartRecords,
selectionInfluencers,
noInfluencersConfigured,
influencersFilterQuery
)
: Promise.resolve(influencers),
viewBySwimlaneState: memoizedLoadViewBySwimlane(
tableSeverity
);
} else {
memoizedAnomalyDataChange(
lastRefresh,
topFieldValues,
{
earliest: overallState.overallSwimlaneData.earliest,
latest: overallState.overallSwimlaneData.latest,
},
selectedJobs,
viewBySwimlaneFieldName,
swimlaneLimit,
influencersFilterQuery,
noInfluencersConfigured
),
}),
(
{ annotationsData, overallState, tableData },
{ influencers, viewBySwimlaneState }
): Partial<ExplorerState> => {
return {
annotationsData,
influencers,
...overallState,
...viewBySwimlaneState,
tableData,
};
}
)
);
}
const loadExplorerData$ = new Subject<LoadExplorerDataConfig>();
const explorerData$ = loadExplorerData$.pipe(
switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config))
);
export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any) => void] => {
const explorerData = useObservable(explorerData$);
return [explorerData, (c) => loadExplorerData$.next(c)];
[],
timerange.earliestMs,
timerange.latestMs,
tableSeverity
);
}
}),
// Load view-by swimlane data and filtered top influencers.
// mergeMap is used to have access to the already fetched data and act on it in arg #1.
// In arg #2 of mergeMap we combine the data and pass it on in the action format
// which can be consumed by explorerReducer() later on.
mergeMap(
({ anomalyChartRecords, influencers, overallState, topFieldValues }) =>
forkJoin({
influencers:
(selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
anomalyChartRecords !== undefined &&
anomalyChartRecords.length > 0
? memoizedLoadFilteredTopInfluencers(
lastRefresh,
jobIds,
timerange.earliestMs,
timerange.latestMs,
anomalyChartRecords,
selectionInfluencers,
noInfluencersConfigured,
influencersFilterQuery
)
: Promise.resolve(influencers),
viewBySwimlaneState: memoizedLoadViewBySwimlane(
lastRefresh,
topFieldValues,
{
earliest: overallState.earliest,
latest: overallState.latest,
},
selectedJobs,
viewBySwimlaneFieldName,
ANOMALY_SWIM_LANE_HARD_LIMIT,
viewByPerPage,
viewByFromPage,
swimlaneContainerWidth,
influencersFilterQuery
),
}),
(
{ annotationsData, overallState, tableData },
{ influencers, viewBySwimlaneState }
): Partial<ExplorerState> => {
return {
annotationsData,
influencers,
loading: false,
viewBySwimlaneDataLoading: false,
overallSwimlaneData: overallState,
viewBySwimlaneData: viewBySwimlaneState,
tableData,
swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState)
? viewBySwimlaneState.cardinality
: undefined,
};
}
)
);
};
};
export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any) => void] => {
const timefilter = useTimefilter();
const {
services: {
mlServices: { mlApiServices },
uiSettings,
},
} = useMlKibana();
const loadExplorerData = useMemo(() => {
const service = new AnomalyTimelineService(
timefilter,
uiSettings,
mlResultsServiceProvider(mlApiServices)
);
return loadExplorerDataProvider(service);
}, []);
const loadExplorerData$ = useMemo(() => new Subject<LoadExplorerDataConfig>(), []);
const explorerData$ = useMemo(() => loadExplorerData$.pipe(switchMap(loadExplorerData)), []);
const explorerData = useObservable(explorerData$);
const update = useCallback((c) => {
loadExplorerData$.next(c);
}, []);
return [explorerData, update];
};

View file

@ -52,7 +52,6 @@ function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) {
interface AddToDashboardControlProps {
jobIds: JobId[];
viewBy: string;
limit: number;
onClose: (callback?: () => Promise<any>) => void;
}
@ -63,7 +62,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
onClose,
jobIds,
viewBy,
limit,
}) => {
const {
notifications: { toasts },
@ -141,7 +139,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
jobIds,
swimlaneType,
viewBy,
limit,
},
};
}
@ -206,8 +203,8 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
{
id: SWIMLANE_TYPE.VIEW_BY,
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
defaultMessage: 'View by {viewByField}, up to {limit} rows',
values: { viewByField: viewBy, limit },
defaultMessage: 'View by {viewByField}',
values: { viewByField: viewBy },
}),
},
];

View file

@ -22,12 +22,11 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants';
import { DRAG_SELECT_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants';
import { AddToDashboardControl } from './add_to_dashboard_control';
import { useMlKibana } from '../contexts/kibana';
import { TimeBuckets } from '../util/time_buckets';
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
import { SelectLimit } from './select_limit';
import {
ALLOW_CELL_RANGE_SELECTION,
dragSelect$,
@ -36,9 +35,9 @@ import {
import { ExplorerState } from './reducers/explorer_reducer';
import { hasMatchingPoints } from './has_matching_points';
import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found';
import { LoadingIndicator } from '../components/loading_indicator';
import { SwimlaneContainer } from './swimlane_container';
import { OverallSwimlaneData } from './explorer_utils';
import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
import { NoOverallData } from './components/no_overall_data';
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
return options.map((option) => ({
@ -132,8 +131,11 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
viewBySwimlaneDataLoading,
viewBySwimlaneFieldName,
viewBySwimlaneOptions,
swimlaneLimit,
selectedJobs,
viewByFromPage,
viewByPerPage,
swimlaneLimit,
loading,
} = explorerState;
const setSwimlaneSelectActive = useCallback((active: boolean) => {
@ -159,25 +161,18 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
}, []);
// Listener for click events in the swimlane to load corresponding anomaly data.
const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => {
// If selectedCells is an empty object we clear any existing selection,
// otherwise we save the new selection in AppState and update the Explorer.
if (Object.keys(selectedCellsUpdate).length === 0) {
setSelectedCells();
} else {
setSelectedCells(selectedCellsUpdate);
}
}, []);
const showOverallSwimlane =
overallSwimlaneData !== null &&
overallSwimlaneData.laneLabels &&
overallSwimlaneData.laneLabels.length > 0;
const showViewBySwimlane =
viewBySwimlaneData !== null &&
viewBySwimlaneData.laneLabels &&
viewBySwimlaneData.laneLabels.length > 0;
const swimlaneCellClick = useCallback(
(selectedCellsUpdate: any) => {
// If selectedCells is an empty object we clear any existing selection,
// otherwise we save the new selection in AppState and update the Explorer.
if (Object.keys(selectedCellsUpdate).length === 0) {
setSelectedCells();
} else {
setSelectedCells(selectedCellsUpdate);
}
},
[setSelectedCells]
);
const menuItems = useMemo(() => {
const items = [];
@ -235,21 +230,6 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<span className="eui-textNoWrap">
<FormattedMessage
id="xpack.ml.explorer.limitLabel"
defaultMessage="Limit"
/>
</span>
}
display={'columnCompressed'}
>
<SelectLimit />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<div className="panel-sub-title">
{viewByLoadedForTimeFormatted && (
@ -305,68 +285,84 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
<EuiSpacer size="m" />
<div
className="ml-explorer-swimlane euiText"
className="mlExplorerSwimlane euiText"
onMouseEnter={onSwimlaneEnterHandler}
onMouseLeave={onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
>
{showOverallSwimlane && (
<SwimlaneContainer
filterActive={filterActive}
maskAll={maskAll}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
swimlaneType={'overall'}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
/>
)}
<SwimlaneContainer
filterActive={filterActive}
maskAll={maskAll}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
swimlaneType={'overall'}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
isLoading={loading}
noDataWarning={<NoOverallData />}
/>
</div>
<EuiSpacer size="m" />
{viewBySwimlaneOptions.length > 0 && (
<>
{showViewBySwimlane && (
<>
<EuiSpacer size="m" />
<div
className="ml-explorer-swimlane euiText"
onMouseEnter={onSwimlaneEnterHandler}
onMouseLeave={onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<SwimlaneContainer
filterActive={filterActive}
maskAll={
maskAll &&
!hasMatchingPoints({
filteredFields,
swimlaneData: viewBySwimlaneData,
})
<>
<div
className="mlExplorerSwimlane euiText"
onMouseEnter={onSwimlaneEnterHandler}
onMouseLeave={onSwimlaneLeaveHandler}
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
>
<SwimlaneContainer
filterActive={filterActive}
maskAll={
maskAll &&
!hasMatchingPoints({
filteredFields,
swimlaneData: viewBySwimlaneData,
})
}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={viewBySwimlaneData as ViewBySwimLaneData}
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
fromPage={viewByFromPage}
perPage={viewByPerPage}
swimlaneLimit={swimlaneLimit}
onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => {
if (perPageUpdate) {
explorerService.setViewByPerPage(perPageUpdate);
}
timeBuckets={timeBuckets}
swimlaneCellClick={swimlaneCellClick}
swimlaneData={viewBySwimlaneData as OverallSwimlaneData}
swimlaneType={'viewBy'}
selection={selectedCells}
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
/>
</div>
</>
)}
{viewBySwimlaneDataLoading && <LoadingIndicator />}
{!showViewBySwimlane &&
!viewBySwimlaneDataLoading &&
typeof viewBySwimlaneFieldName === 'string' && (
<ExplorerNoInfluencersFound
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
showFilterMessage={filterActive === true}
if (fromPageUpdate) {
explorerService.setViewByFromPage(fromPageUpdate);
}
}}
isLoading={loading || viewBySwimlaneDataLoading}
noDataWarning={
typeof viewBySwimlaneFieldName === 'string' ? (
viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? (
<FormattedMessage
id="xpack.ml.explorer.noResultForSelectedJobsMessage"
defaultMessage="No results found for selected {jobsCount, plural, one {job} other {jobs}}"
values={{ jobsCount: selectedJobs?.length ?? 1 }}
/>
) : (
<ExplorerNoInfluencersFound
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
showFilterMessage={filterActive === true}
/>
)
) : null
}
/>
)}
</div>
</>
</>
)}
</EuiPanel>
@ -380,7 +376,6 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
}}
jobIds={selectedJobs.map(({ id }) => id)}
viewBy={viewBySwimlaneFieldName!}
limit={swimlaneLimit}
/>
)}
</>

View file

@ -1,20 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
id="xpack.ml.explorer.noInfluencersFoundTitle"
values={
Object {
"viewBySwimlaneFieldName": "field_name",
}
}
/>
</h2>
<FormattedMessage
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
id="xpack.ml.explorer.noInfluencersFoundTitle"
values={
Object {
"viewBySwimlaneFieldName": "field_name",
}
}
titleSize="xs"
/>
`;

View file

@ -7,7 +7,6 @@
import React, { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
/*
* React component for rendering EuiEmptyPrompt when no influencers were found.
@ -15,26 +14,17 @@ import { EuiEmptyPrompt } from '@elastic/eui';
export const ExplorerNoInfluencersFound: FC<{
viewBySwimlaneFieldName: string;
showFilterMessage?: boolean;
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => (
<EuiEmptyPrompt
titleSize="xs"
title={
<h2>
{showFilterMessage === false && (
<FormattedMessage
id="xpack.ml.explorer.noInfluencersFoundTitle"
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
values={{ viewBySwimlaneFieldName }}
/>
)}
{showFilterMessage === true && (
<FormattedMessage
id="xpack.ml.explorer.noInfluencersFoundTitleFilterMessage"
defaultMessage="No {viewBySwimlaneFieldName} influencers found for specified filter"
values={{ viewBySwimlaneFieldName }}
/>
)}
</h2>
}
/>
);
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) =>
showFilterMessage === false ? (
<FormattedMessage
id="xpack.ml.explorer.noInfluencersFoundTitle"
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
values={{ viewBySwimlaneFieldName }}
/>
) : (
<FormattedMessage
id="xpack.ml.explorer.noInfluencersFoundTitleFilterMessage"
defaultMessage="No {viewBySwimlaneFieldName} influencers found for specified filter"
values={{ viewBySwimlaneFieldName }}
/>
);

View file

@ -0,0 +1,17 @@
/*
* 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 } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
export const NoOverallData: FC = () => {
return (
<FormattedMessage
id="xpack.ml.anomalySwimLane.noOverallDataMessage"
defaultMessage="No overall data found"
/>
);
};

View file

@ -12,8 +12,6 @@ import PropTypes from 'prop-types';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import {
EuiFlexGroup,
@ -27,6 +25,7 @@ import {
EuiPageHeaderSection,
EuiSpacer,
EuiTitle,
EuiLoadingContent,
} from '@elastic/eui';
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
@ -36,12 +35,10 @@ import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wra
import { InfluencersList } from '../components/influencers_list';
import { explorerService } from './explorer_dashboard_service';
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { NavigationMenu } from '../components/navigation_menu';
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
import { JobSelector } from '../components/job_selector';
import { SelectInterval } from '../components/controls/select_interval/select_interval';
import { limit$ } from './select_limit/select_limit';
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
import {
ExplorerQueryBar,
@ -142,19 +139,6 @@ export class Explorer extends React.Component {
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
_unsubscribeAll = new Subject();
componentDidMount() {
limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit);
}
componentWillUnmount() {
this._unsubscribeAll.next();
this._unsubscribeAll.complete();
}
viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value);
// Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes
// and will cause a syntax error when called with getKqlQueryValues
applyFilter = (fieldName, fieldValue, action) => {
@ -240,29 +224,7 @@ export class Explorer extends React.Component {
const noJobsFound = selectedJobs === null || selectedJobs.length === 0;
const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0;
if (loading === true) {
return (
<ExplorerPage
jobSelectorProps={jobSelectorProps}
noInfluencersConfigured={noInfluencersConfigured}
influencers={influencers}
filterActive={filterActive}
filterPlaceHolder={filterPlaceHolder}
filterIconTriggeredQuery={this.state.filterIconTriggeredQuery}
indexPattern={indexPattern}
queryString={queryString}
updateLanguage={this.updateLanguage}
>
<LoadingIndicator
label={i18n.translate('xpack.ml.explorer.loadingLabel', {
defaultMessage: 'Loading',
})}
/>
</ExplorerPage>
);
}
if (noJobsFound) {
if (noJobsFound && !loading) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoJobsFound />
@ -270,7 +232,7 @@ export class Explorer extends React.Component {
);
}
if (noJobsFound && hasResults === false) {
if (noJobsFound && hasResults === false && !loading) {
return (
<ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoResultsFound />
@ -320,7 +282,11 @@ export class Explorer extends React.Component {
/>
</h2>
</EuiTitle>
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} />
{loading ? (
<EuiLoadingContent lines={10} />
) : (
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} />
)}
</div>
)}
@ -352,59 +318,59 @@ export class Explorer extends React.Component {
</>
)}
<EuiTitle className="panel-title">
<h2>
<FormattedMessage
id="xpack.ml.explorer.anomaliesTitle"
defaultMessage="Anomalies"
{loading === false && (
<>
<EuiTitle className="panel-title">
<h2>
<FormattedMessage
id="xpack.ml.explorer.anomaliesTitle"
defaultMessage="Anomalies"
/>
</h2>
</EuiTitle>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.intervalLabel', {
defaultMessage: 'Interval',
})}
>
<SelectInterval />
</EuiFormRow>
</EuiFlexItem>
{chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
{showCharts && <ExplorerChartsContainer {...{ ...chartsData, severity }} />}
</div>
<AnomaliesTable
bounds={bounds}
tableData={tableData}
influencerFilter={this.applyFilter}
/>
</h2>
</EuiTitle>
<EuiFlexGroup
direction="row"
gutterSize="l"
responsive={true}
className="ml-anomalies-controls"
>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
defaultMessage: 'Severity threshold',
})}
>
<SelectSeverity />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '170px' }}>
<EuiFormRow
label={i18n.translate('xpack.ml.explorer.intervalLabel', {
defaultMessage: 'Interval',
})}
>
<SelectInterval />
</EuiFormRow>
</EuiFlexItem>
{chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && (
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
<EuiFormRow label="&#8203;">
<CheckboxShowCharts />
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<div className="euiText explorer-charts">
{showCharts && <ExplorerChartsContainer {...{ ...chartsData, severity }} />}
</div>
<AnomaliesTable
bounds={bounds}
tableData={tableData}
influencerFilter={this.applyFilter}
/>
</>
)}
</div>
</div>
</ExplorerPage>

View file

@ -27,9 +27,10 @@ export const EXPLORER_ACTION = {
SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings',
SET_SELECTED_CELLS: 'setSelectedCells',
SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth',
SET_SWIMLANE_LIMIT: 'setSwimlaneLimit',
SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName',
SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading',
SET_VIEW_BY_PER_PAGE: 'setViewByPerPage',
SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage',
};
export const FILTER_ACTION = {
@ -51,9 +52,23 @@ export const CHART_TYPE = {
};
export const MAX_CATEGORY_EXAMPLES = 10;
/**
* Maximum amount of top influencer to fetch.
*/
export const MAX_INFLUENCER_FIELD_VALUES = 10;
export const MAX_INFLUENCER_FIELD_NAMES = 50;
export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', {
defaultMessage: 'job ID',
});
/**
* Hard limitation for the size of terms
* aggregations on influencers values.
*/
export const ANOMALY_SWIM_LANE_HARD_LIMIT = 1000;
/**
* Default page size fot the anomaly swim lane.
*/
export const SWIM_LANE_DEFAULT_PAGE_SIZE = 10;

View file

@ -12,7 +12,7 @@
import { isEqual } from 'lodash';
import { from, isObservable, Observable, Subject } from 'rxjs';
import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators';
import { distinctUntilChanged, flatMap, map, scan, shareReplay } from 'rxjs/operators';
import { DeepPartial } from '../../../common/types/common';
@ -49,7 +49,9 @@ const explorerFilteredAction$ = explorerAction$.pipe(
// applies action and returns state
const explorerState$: Observable<ExplorerState> = explorerFilteredAction$.pipe(
scan(explorerReducer, getExplorerDefaultState())
scan(explorerReducer, getExplorerDefaultState()),
// share the last emitted value among new subscribers
shareReplay(1)
);
interface ExplorerAppState {
@ -59,6 +61,8 @@ interface ExplorerAppState {
selectedTimes?: number[];
showTopFieldValues?: boolean;
viewByFieldName?: string;
viewByPerPage?: number;
viewByFromPage?: number;
};
mlExplorerFilter: {
influencersFilterQuery?: unknown;
@ -88,6 +92,14 @@ const explorerAppState$: Observable<ExplorerAppState> = explorerState$.pipe(
appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName;
}
if (state.viewByFromPage !== undefined) {
appState.mlExplorerSwimlane.viewByFromPage = state.viewByFromPage;
}
if (state.viewByPerPage !== undefined) {
appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage;
}
if (state.filterActive) {
appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery;
appState.mlExplorerFilter.filterActive = state.filterActive;
@ -153,13 +165,16 @@ export const explorerService = {
payload,
});
},
setSwimlaneLimit: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_LIMIT, payload });
},
setViewBySwimlaneFieldName: (payload: string) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload });
},
setViewBySwimlaneLoading: (payload: any) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload });
},
setViewByFromPage: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE, payload });
},
setViewByPerPage: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload });
},
};

View file

@ -29,7 +29,7 @@ import {
ChartTooltipService,
ChartTooltipValue,
} from '../components/chart_tooltip/chart_tooltip_service';
import { OverallSwimlaneData } from './explorer_utils';
import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
const SCSS = {
mlDragselectDragging: 'mlDragselectDragging',
@ -57,7 +57,7 @@ export interface ExplorerSwimlaneProps {
maskAll?: boolean;
timeBuckets: InstanceType<typeof TimeBucketsClass>;
swimlaneCellClick?: Function;
swimlaneData: OverallSwimlaneData;
swimlaneData: OverallSwimlaneData | ViewBySwimLaneData;
swimlaneType: SwimlaneType;
selection?: {
lanes: any[];
@ -211,7 +211,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
const { swimlaneType } = this.props;
// This selects both overall and viewby swimlane
const wrapper = d3.selectAll('.ml-explorer-swimlane');
const wrapper = d3.selectAll('.mlExplorerSwimlane');
wrapper.selectAll('.lane-label').classed('lane-label-masked', true);
wrapper
@ -242,7 +242,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
maskIrrelevantSwimlanes(maskAll: boolean) {
if (maskAll === true) {
// This selects both overall and viewby swimlane
const allSwimlanes = d3.selectAll('.ml-explorer-swimlane');
const allSwimlanes = d3.selectAll('.mlExplorerSwimlane');
allSwimlanes.selectAll('.lane-label').classed('lane-label-masked', true);
allSwimlanes
.selectAll('.sl-cell-inner,.sl-cell-inner-dragselect')
@ -258,7 +258,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
clearSelection() {
// This selects both overall and viewby swimlane
const wrapper = d3.selectAll('.ml-explorer-swimlane');
const wrapper = d3.selectAll('.mlExplorerSwimlane');
wrapper.selectAll('.lane-label').classed('lane-label-masked', false);
wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', false);

View file

@ -8,8 +8,6 @@ import { Moment } from 'moment';
import { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
import { TimeBucketsInterval } from '../util/time_buckets';
interface ClearedSelectedAnomaliesState {
selectedCells: undefined;
viewByLoadedForTimeFormatted: null;
@ -35,6 +33,10 @@ export declare interface OverallSwimlaneData extends SwimlaneData {
latest: number;
}
export interface ViewBySwimLaneData extends OverallSwimlaneData {
cardinality: number;
}
export declare const getDateFormatTz: () => any;
export declare const getDefaultSwimlaneData: () => SwimlaneData;
@ -163,22 +165,6 @@ declare interface LoadOverallDataResponse {
overallSwimlaneData: OverallSwimlaneData;
}
export declare const loadOverallData: (
selectedJobs: ExplorerJob[],
interval: TimeBucketsInterval,
bounds: TimeRangeBounds
) => Promise<LoadOverallDataResponse>;
export declare const loadViewBySwimlane: (
fieldValues: string[],
bounds: SwimlaneBounds,
selectedJobs: ExplorerJob[],
viewBySwimlaneFieldName: string,
swimlaneLimit: number,
influencersFilterQuery: any,
noInfluencersConfigured: boolean
) => Promise<any>;
export declare const loadViewByTopFieldValuesForSelectedTime: (
earliestMs: number,
latestMs: number,

View file

@ -8,11 +8,9 @@
* utils for Anomaly Explorer.
*/
import { chain, each, get, union, uniq } from 'lodash';
import { chain, get, union, uniq } from 'lodash';
import moment from 'moment-timezone';
import { i18n } from '@kbn/i18n';
import {
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE,
@ -27,7 +25,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 { getTimeBucketsFromCache } from '../util/time_buckets';
import { getTimefilter, getUiSettings } from '../util/dependency_cache';
import {
@ -36,7 +34,6 @@ import {
SWIMLANE_TYPE,
VIEW_BY_JOB_LABEL,
} from './explorer_constants';
import { getSwimlaneContainerWidth } from './legacy_utils';
// create new job objects based on standard job config objects
// new job objects just contain job id, bucket span in seconds and a selected flag.
@ -51,6 +48,7 @@ export function getClearedSelectedAnomaliesState() {
return {
selectedCells: undefined,
viewByLoadedForTimeFormatted: null,
swimlaneLimit: undefined,
};
}
@ -267,58 +265,6 @@ export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth)
return buckets.getInterval();
}
export function loadViewByTopFieldValuesForSelectedTime(
earliestMs,
latestMs,
selectedJobs,
viewBySwimlaneFieldName,
swimlaneLimit,
noInfluencersConfigured
) {
const selectedJobIds = selectedJobs.map((d) => d.id);
// Find the top field values for the selected time, and then load the 'view by'
// swimlane over the full time range for those specific field values.
return new Promise((resolve) => {
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
mlResultsService
.getTopInfluencers(selectedJobIds, earliestMs, latestMs, swimlaneLimit)
.then((resp) => {
if (resp.influencers[viewBySwimlaneFieldName] === undefined) {
resolve([]);
}
const topFieldValues = [];
const topInfluencers = resp.influencers[viewBySwimlaneFieldName];
if (Array.isArray(topInfluencers)) {
topInfluencers.forEach((influencerData) => {
if (influencerData.maxAnomalyScore > 0) {
topFieldValues.push(influencerData.influencerFieldValue);
}
});
}
resolve(topFieldValues);
});
} else {
mlResultsService
.getScoresByBucket(
selectedJobIds,
earliestMs,
latestMs,
getSwimlaneBucketInterval(
selectedJobs,
getSwimlaneContainerWidth(noInfluencersConfigured)
).asSeconds() + 's',
swimlaneLimit
)
.then((resp) => {
const topFieldValues = Object.keys(resp.results);
resolve(topFieldValues);
});
}
});
}
// Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName
export function getViewBySwimlaneOptions({
currentViewBySwimlaneFieldName,
@ -435,105 +381,6 @@ export function getViewBySwimlaneOptions({
};
}
export function processOverallResults(scoresByTime, searchBounds, interval) {
const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', {
defaultMessage: 'Overall',
});
const dataset = {
laneLabels: [overallLabel],
points: [],
interval,
earliest: searchBounds.min.valueOf() / 1000,
latest: searchBounds.max.valueOf() / 1000,
};
if (Object.keys(scoresByTime).length > 0) {
// 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.
each(scoresByTime, (score, timeMs) => {
const time = 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;
}
export function processViewByResults(
scoresByInfluencerAndTime,
sortedLaneValues,
bounds,
viewBySwimlaneFieldName,
interval
) {
// 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 = {
fieldName: viewBySwimlaneFieldName,
points: [],
interval,
};
// Set the earliest and latest to be the same as the overall swimlane.
dataset.earliest = bounds.earliest;
dataset.latest = bounds.latest;
const laneLabels = [];
const maxScoreByLaneLabel = {};
each(scoresByInfluencerAndTime, (influencerData, influencerFieldValue) => {
laneLabels.push(influencerFieldValue);
maxScoreByLaneLabel[influencerFieldValue] = 0;
each(influencerData, (anomalyScore, timeMs) => {
const time = 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 = 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 = 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;
}
export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) {
const jobIds =
selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
@ -723,138 +570,6 @@ export async function loadDataForCharts(
});
}
export function loadOverallData(selectedJobs, interval, bounds) {
return new Promise((resolve) => {
// Loads the overall data components i.e. the overall swimlane and influencers list.
if (selectedJobs === null) {
resolve({
loading: false,
hasResuts: false,
});
return;
}
// 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);
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'
)
.then((resp) => {
const overallSwimlaneData = processOverallResults(
resp.results,
searchBounds,
interval.asSeconds()
);
resolve({
loading: false,
overallSwimlaneData,
});
});
});
}
export function loadViewBySwimlane(
fieldValues,
bounds,
selectedJobs,
viewBySwimlaneFieldName,
swimlaneLimit,
influencersFilterQuery,
noInfluencersConfigured
) {
return new Promise((resolve) => {
const finish = (resp) => {
if (resp !== undefined) {
const viewBySwimlaneData = processViewByResults(
resp.results,
fieldValues,
bounds,
viewBySwimlaneFieldName,
getSwimlaneBucketInterval(
selectedJobs,
getSwimlaneContainerWidth(noInfluencersConfigured)
).asSeconds()
);
resolve({
viewBySwimlaneData,
viewBySwimlaneDataLoading: false,
});
} else {
resolve({ viewBySwimlaneDataLoading: false });
}
};
if (selectedJobs === undefined || viewBySwimlaneFieldName === undefined) {
finish();
return;
} else {
// Ensure the search bounds align to the bucketing interval used in the swimlane so
// that the first and last buckets are complete.
const timefilter = getTimefilter();
const timefilterBounds = timefilter.getActiveBounds();
const searchBounds = getBoundsRoundedToInterval(
timefilterBounds,
getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)),
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 = `${getSwimlaneBucketInterval(
selectedJobs,
getSwimlaneContainerWidth(noInfluencersConfigured)
).asSeconds()}s`;
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
mlResultsService
.getInfluencerValueMaxScoreByTime(
selectedJobIds,
viewBySwimlaneFieldName,
fieldValues,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit,
influencersFilterQuery
)
.then(finish);
} else {
const jobIds =
fieldValues !== undefined && fieldValues.length > 0 ? fieldValues : selectedJobIds;
mlResultsService
.getScoresByBucket(
jobIds,
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit
)
.then(finish);
}
}
});
}
export async function loadTopInfluencers(
selectedJobIds,
earliestMs,
@ -871,6 +586,8 @@ export async function loadTopInfluencers(
earliestMs,
latestMs,
MAX_INFLUENCER_FIELD_VALUES,
10,
1,
influencers,
influencersFilterQuery
)

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useMemo } from 'react';
import { useUrlState } from '../../util/url_state';
import { SWIMLANE_TYPE } from '../explorer_constants';
import { AppStateSelectedCells } from '../explorer_utils';
@ -14,55 +15,55 @@ export const useSelectedCells = (): [
] => {
const [appState, setAppState] = useUrlState('_a');
let selectedCells: AppStateSelectedCells | undefined;
// keep swimlane selection, restore selectedCells from AppState
if (
appState &&
appState.mlExplorerSwimlane &&
appState.mlExplorerSwimlane.selectedType !== undefined
) {
selectedCells = {
type: appState.mlExplorerSwimlane.selectedType,
lanes: appState.mlExplorerSwimlane.selectedLanes,
times: appState.mlExplorerSwimlane.selectedTimes,
showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues,
viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName,
};
}
const selectedCells = useMemo(() => {
return appState?.mlExplorerSwimlane?.selectedType !== undefined
? {
type: appState.mlExplorerSwimlane.selectedType,
lanes: appState.mlExplorerSwimlane.selectedLanes,
times: appState.mlExplorerSwimlane.selectedTimes,
showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues,
viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName,
}
: undefined;
// TODO fix appState to use memoization
}, [JSON.stringify(appState?.mlExplorerSwimlane)]);
const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => {
const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane };
const setSelectedCells = useCallback(
(swimlaneSelectedCells: AppStateSelectedCells) => {
const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane };
if (swimlaneSelectedCells !== undefined) {
swimlaneSelectedCells.showTopFieldValues = false;
if (swimlaneSelectedCells !== undefined) {
swimlaneSelectedCells.showTopFieldValues = false;
const currentSwimlaneType = selectedCells?.type;
const currentShowTopFieldValues = selectedCells?.showTopFieldValues;
const newSwimlaneType = swimlaneSelectedCells?.type;
const currentSwimlaneType = selectedCells?.type;
const currentShowTopFieldValues = selectedCells?.showTopFieldValues;
const newSwimlaneType = swimlaneSelectedCells?.type;
if (
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
currentShowTopFieldValues === true
) {
swimlaneSelectedCells.showTopFieldValues = true;
if (
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
currentShowTopFieldValues === true
) {
swimlaneSelectedCells.showTopFieldValues = true;
}
mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
} else {
delete mlExplorerSwimlane.selectedType;
delete mlExplorerSwimlane.selectedLanes;
delete mlExplorerSwimlane.selectedTimes;
delete mlExplorerSwimlane.showTopFieldValues;
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
}
mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
} else {
delete mlExplorerSwimlane.selectedType;
delete mlExplorerSwimlane.selectedLanes;
delete mlExplorerSwimlane.selectedTimes;
delete mlExplorerSwimlane.showTopFieldValues;
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
}
};
},
[appState?.mlExplorerSwimlane, selectedCells]
);
return [selectedCells, setSelectedCells];
};

View file

@ -11,8 +11,3 @@ export function getChartContainerWidth() {
const chartContainer = document.querySelector('.explorer-charts');
return Math.floor((chartContainer && chartContainer.clientWidth) || 0);
}
export function getSwimlaneContainerWidth() {
const explorerContainer = document.querySelector('.ml-explorer');
return (explorerContainer && explorerContainer.clientWidth) || 0;
}

View file

@ -19,5 +19,6 @@ export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerSta
queryString: '',
tableQueryString: '',
...getClearedSelectedAnomaliesState(),
viewByFromPage: 1,
};
}

View file

@ -17,6 +17,7 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload)
noInfluencersConfigured: getInfluencers(selectedJobs).length === 0,
overallSwimlaneData: getDefaultSwimlaneData(),
selectedJobs,
viewByFromPage: 1,
};
// clear filter if selected jobs have no influencers

View file

@ -27,7 +27,7 @@ import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder';
export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => {
const { type, payload } = nextAction;
let nextState;
let nextState: ExplorerState;
switch (type) {
case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS:
@ -39,6 +39,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...state,
...getClearedSelectedAnomaliesState(),
loading: false,
viewByFromPage: 1,
selectedJobs: [],
};
break;
@ -82,22 +83,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
break;
case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH:
if (state.noInfluencersConfigured === true) {
// swimlane is full width, minus 30 for the 'no influencers' info icon,
// minus 170 for the lane labels, minus 50 padding
nextState = { ...state, swimlaneContainerWidth: payload - 250 };
} else {
// swimlane width is 5 sixths of the window,
// minus 170 for the lane labels, minus 50 padding
nextState = { ...state, swimlaneContainerWidth: (payload / 6) * 5 - 220 };
}
break;
case EXPLORER_ACTION.SET_SWIMLANE_LIMIT:
nextState = {
...state,
swimlaneLimit: payload,
};
nextState = { ...state, swimlaneContainerWidth: payload };
break;
case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME:
@ -117,6 +103,9 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...getClearedSelectedAnomaliesState(),
maskAll,
viewBySwimlaneFieldName,
viewBySwimlaneData: getDefaultSwimlaneData(),
viewByFromPage: 1,
viewBySwimlaneDataLoading: true,
};
break;
@ -125,7 +114,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
nextState = {
...state,
annotationsData,
...overallState,
overallSwimlaneData: overallState,
tableData,
viewBySwimlaneData: {
...getDefaultSwimlaneData(),
@ -134,6 +123,22 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
};
break;
case EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE:
nextState = {
...state,
viewByFromPage: payload,
};
break;
case EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE:
nextState = {
...state,
// reset current page on the page size change
viewByFromPage: 1,
viewByPerPage: payload,
};
break;
default:
nextState = state;
}
@ -155,7 +160,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
filteredFields: nextState.filteredFields,
isAndOperator: nextState.isAndOperator,
selectedJobs: nextState.selectedJobs,
selectedCells: nextState.selectedCells,
selectedCells: nextState.selectedCells!,
});
const { bounds, selectedCells } = nextState;

View file

@ -57,5 +57,6 @@ export function setInfluencerFilterSettings(
filteredFields.includes(selectedViewByFieldName) === false,
viewBySwimlaneFieldName: selectedViewByFieldName,
viewBySwimlaneOptions: filteredViewBySwimlaneOptions,
viewByFromPage: 1,
};
}

View file

@ -19,7 +19,9 @@ import {
TimeRangeBounds,
OverallSwimlaneData,
SwimlaneData,
ViewBySwimLaneData,
} from '../../explorer_utils';
import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants';
export interface ExplorerState {
annotationsData: any[];
@ -42,14 +44,16 @@ export interface ExplorerState {
selectedJobs: ExplorerJob[] | null;
swimlaneBucketInterval: any;
swimlaneContainerWidth: number;
swimlaneLimit: number;
tableData: AnomaliesTableData;
tableQueryString: string;
viewByLoadedForTimeFormatted: string | null;
viewBySwimlaneData: SwimlaneData | OverallSwimlaneData;
viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData;
viewBySwimlaneDataLoading: boolean;
viewBySwimlaneFieldName?: string;
viewByPerPage: number;
viewByFromPage: number;
viewBySwimlaneOptions: string[];
swimlaneLimit?: number;
}
function getDefaultIndexPattern() {
@ -78,7 +82,6 @@ export function getExplorerDefaultState(): ExplorerState {
selectedJobs: null,
swimlaneBucketInterval: undefined,
swimlaneContainerWidth: 0,
swimlaneLimit: 10,
tableData: {
anomalies: [],
examplesByJobId: [''],
@ -92,5 +95,8 @@ export function getExplorerDefaultState(): ExplorerState {
viewBySwimlaneDataLoading: false,
viewBySwimlaneFieldName: undefined,
viewBySwimlaneOptions: [],
viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE,
viewByFromPage: 1,
swimlaneLimit: undefined,
};
}

View file

@ -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 { useSwimlaneLimit, SelectLimit } from './select_limit';

View file

@ -1,29 +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 { act } from 'react-dom/test-utils';
import { shallow } from 'enzyme';
import { SelectLimit } from './select_limit';
describe('SelectLimit', () => {
test('creates correct initial selected value', () => {
const wrapper = shallow(<SelectLimit />);
expect(wrapper.props().value).toEqual(10);
});
test('state for currently selected value is updated correctly on click', () => {
const wrapper = shallow(<SelectLimit />);
expect(wrapper.props().value).toEqual(10);
act(() => {
wrapper.simulate('change', { target: { value: 25 } });
});
wrapper.update();
expect(wrapper.props().value).toEqual(10);
});
});

View file

@ -1,40 +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.
*/
/*
* React component for rendering a select element with limit options.
*/
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject } from 'rxjs';
import { EuiSelect } from '@elastic/eui';
const limitOptions = [5, 10, 25, 50];
const euiOptions = limitOptions.map((limit) => ({
value: limit,
text: `${limit}`,
}));
export const defaultLimit = limitOptions[1];
export const limit$ = new BehaviorSubject<number>(defaultLimit);
export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => {
const limit = useObservable(limit$, defaultLimit);
return [limit!, (newLimit: number) => limit$.next(newLimit)];
};
export const SelectLimit = () => {
const [limit, setLimit] = useSwimlaneLimit();
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
setLimit(parseInt(e.target.value, 10));
}
return <EuiSelect compressed options={euiOptions} onChange={onChange} value={limit} />;
};

View file

@ -5,7 +5,14 @@
*/
import React, { FC, useCallback, useState } from 'react';
import { EuiResizeObserver, EuiText } from '@elastic/eui';
import {
EuiText,
EuiLoadingChart,
EuiResizeObserver,
EuiFlexGroup,
EuiFlexItem,
EuiEmptyPrompt,
} from '@elastic/eui';
import { throttle } from 'lodash';
import {
@ -14,48 +21,139 @@ import {
} from '../../application/explorer/explorer_swimlane';
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
import { SwimLanePagination } from './swimlane_pagination';
import { SWIMLANE_TYPE } from './explorer_constants';
import { ViewBySwimLaneData } from './explorer_utils';
/**
* Ignore insignificant resize, e.g. browser scrollbar appearance.
*/
const RESIZE_IGNORED_DIFF_PX = 20;
const RESIZE_THROTTLE_TIME_MS = 500;
export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData {
return arg && arg.hasOwnProperty('cardinality');
}
/**
* Anomaly swim lane container responsible for handling resizing, pagination and injecting
* tooltip service.
*
* @param children
* @param onResize
* @param perPage
* @param fromPage
* @param swimlaneLimit
* @param onPaginationChange
* @param props
* @constructor
*/
export const SwimlaneContainer: FC<
Omit<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService'> & {
onResize: (width: number) => void;
fromPage?: number;
perPage?: number;
swimlaneLimit?: number;
onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void;
isLoading: boolean;
noDataWarning: string | JSX.Element | null;
}
> = ({ children, onResize, ...props }) => {
> = ({
children,
onResize,
perPage,
fromPage,
swimlaneLimit,
onPaginationChange,
isLoading,
noDataWarning,
...props
}) => {
const [chartWidth, setChartWidth] = useState<number>(0);
const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => {
const labelWidth = 200;
setChartWidth(e.width - labelWidth);
onResize(e.width);
const resultNewWidth = e.width - labelWidth;
if (Math.abs(resultNewWidth - chartWidth) > RESIZE_IGNORED_DIFF_PX) {
setChartWidth(resultNewWidth);
onResize(resultNewWidth);
}
}, RESIZE_THROTTLE_TIME_MS),
[]
[chartWidth]
);
const showSwimlane =
props.swimlaneData &&
props.swimlaneData.laneLabels &&
props.swimlaneData.laneLabels.length > 0 &&
props.swimlaneData.points.length > 0;
const isPaginationVisible =
(showSwimlane || isLoading) &&
swimlaneLimit !== undefined &&
onPaginationChange &&
props.swimlaneType === SWIMLANE_TYPE.VIEW_BY &&
fromPage &&
perPage;
return (
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<div
ref={(el) => {
resizeRef(el);
}}
>
<div style={{ width: '100%' }}>
<EuiText color="subdued" size="s">
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={chartWidth}
tooltipService={tooltipService}
{...props}
<>
<EuiResizeObserver onResize={resizeHandler}>
{(resizeRef) => (
<EuiFlexGroup
gutterSize={'none'}
direction={'column'}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
ref={(el) => {
resizeRef(el);
}}
data-test-subj="mlSwimLaneContainer"
>
<EuiFlexItem style={{ width: '100%', overflowY: 'auto' }} grow={false}>
<EuiText color="subdued" size="s">
{showSwimlane && !isLoading && (
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={chartWidth}
tooltipService={tooltipService}
{...props}
/>
)}
</MlTooltipComponent>
)}
{isLoading && (
<EuiText textAlign={'center'}>
<EuiLoadingChart
size="xl"
mono={true}
data-test-subj="mlSwimLaneLoadingIndicator"
/>
</EuiText>
)}
{!isLoading && !showSwimlane && (
<EuiEmptyPrompt
titleSize="xs"
style={{ padding: 0 }}
title={<h2>{noDataWarning}</h2>}
/>
)}
</MlTooltipComponent>
</EuiText>
</div>
</div>
)}
</EuiResizeObserver>
</EuiText>
</EuiFlexItem>
{isPaginationVisible && (
<EuiFlexItem grow={false}>
<SwimLanePagination
cardinality={swimlaneLimit!}
fromPage={fromPage!}
perPage={perPage!}
onPaginationChange={onPaginationChange!}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</EuiResizeObserver>
</>
);
};

View file

@ -0,0 +1,108 @@
/*
* 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, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiContextMenuPanel,
EuiPagination,
EuiContextMenuItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
interface SwimLanePaginationProps {
fromPage: number;
perPage: number;
cardinality: number;
onPaginationChange: (arg: { perPage?: number; fromPage?: number }) => void;
}
export const SwimLanePagination: FC<SwimLanePaginationProps> = ({
cardinality,
fromPage,
perPage,
onPaginationChange,
}) => {
const componentFromPage = fromPage - 1;
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onButtonClick = () => setIsPopoverOpen(() => !isPopoverOpen);
const closePopover = () => setIsPopoverOpen(false);
const goToPage = useCallback((pageNumber: number) => {
onPaginationChange({ fromPage: pageNumber + 1 });
}, []);
const setPerPage = useCallback((perPageUpdate: number) => {
onPaginationChange({ perPage: perPageUpdate });
}, []);
const pageCount = Math.ceil(cardinality / perPage);
const items = [5, 10, 20, 50, 100].map((v) => {
return (
<EuiContextMenuItem
key={`${v}_rows`}
icon={v === perPage ? 'check' : 'empty'}
onClick={() => {
closePopover();
setPerPage(v);
}}
>
<FormattedMessage
id="xpack.ml.explorer.swimLaneSelectRowsPerPage"
defaultMessage="{rowsCount} rows"
values={{ rowsCount: v }}
/>
</EuiContextMenuItem>
);
});
return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonEmpty
size="xs"
color="text"
iconType="arrowDown"
iconSide="right"
onClick={onButtonClick}
>
<FormattedMessage
id="xpack.ml.explorer.swimLaneRowsPerPage"
defaultMessage="Rows per page: {rowsCount}"
values={{ rowsCount: perPage }}
/>
</EuiButtonEmpty>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPagination
aria-label={i18n.translate('xpack.ml.explorer.swimLanePagination', {
defaultMessage: 'Anomaly swim lane pagination',
})}
pageCount={pageCount}
activePage={componentFromPage}
onPageClick={goToPage}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -22,7 +22,6 @@ import { ml } from '../../services/ml_api_service';
import { useExplorerData } from '../../explorer/actions';
import { explorerService } from '../../explorer/explorer_dashboard_service';
import { getDateFormatTz } from '../../explorer/explorer_utils';
import { useSwimlaneLimit } from '../../explorer/select_limit';
import { useJobSelection } from '../../components/job_selector/use_job_selection';
import { useShowCharts } from '../../components/controls/checkbox_showcharts';
import { useTableInterval } from '../../components/controls/select_interval';
@ -30,6 +29,7 @@ import { useTableSeverity } from '../../components/controls/select_severity';
import { useUrlState } from '../../util/url_state';
import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs';
import { useTimefilter } from '../../contexts/kibana';
import { isViewBySwimLaneData } from '../../explorer/swimlane_container';
const breadcrumbs = [
ML_BREADCRUMB,
@ -151,10 +151,6 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
const [showCharts] = useShowCharts();
const [tableInterval] = useTableInterval();
const [tableSeverity] = useTableSeverity();
const [swimlaneLimit] = useSwimlaneLimit();
useEffect(() => {
explorerService.setSwimlaneLimit(swimlaneLimit);
}, [swimlaneLimit]);
const [selectedCells, setSelectedCells] = useSelectedCells();
useEffect(() => {
@ -170,14 +166,26 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
selectedCells,
selectedJobs: explorerState.selectedJobs,
swimlaneBucketInterval: explorerState.swimlaneBucketInterval,
swimlaneLimit: explorerState.swimlaneLimit,
tableInterval: tableInterval.val,
tableSeverity: tableSeverity.val,
viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName,
swimlaneContainerWidth: explorerState.swimlaneContainerWidth,
viewByPerPage: explorerState.viewByPerPage,
viewByFromPage: explorerState.viewByFromPage,
}) ||
undefined;
useEffect(() => {
loadExplorerData(loadExplorerDataConfig);
if (explorerState && explorerState.swimlaneContainerWidth > 0) {
loadExplorerData({
...loadExplorerDataConfig,
swimlaneLimit:
explorerState?.viewBySwimlaneData &&
isViewBySwimLaneData(explorerState?.viewBySwimlaneData)
? explorerState?.viewBySwimlaneData.cardinality
: undefined,
});
}
}, [JSON.stringify(loadExplorerDataConfig)]);
if (explorerState === undefined || refresh === undefined || showCharts === undefined) {

View file

@ -12,41 +12,47 @@ import { I18nProvider } from '@kbn/i18n/react';
import { TimeSeriesExplorerUrlStateManager } from './timeseriesexplorer';
jest.mock('../../contexts/kibana/kibana_context', () => ({
useMlKibana: () => {
return {
services: {
uiSettings: { get: jest.fn() },
data: {
query: {
timefilter: {
jest.mock('../../contexts/kibana/kibana_context', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { of } = require('rxjs');
return {
useMlKibana: () => {
return {
services: {
uiSettings: { get: jest.fn() },
data: {
query: {
timefilter: {
disableTimeRangeSelector: jest.fn(),
disableAutoRefreshSelector: jest.fn(),
enableTimeRangeSelector: jest.fn(),
enableAutoRefreshSelector: jest.fn(),
getRefreshInterval: jest.fn(),
setRefreshInterval: jest.fn(),
getTime: jest.fn(),
isAutoRefreshSelectorEnabled: jest.fn(),
isTimeRangeSelectorEnabled: jest.fn(),
getRefreshIntervalUpdate$: jest.fn(),
getTimeUpdate$: jest.fn(),
getEnabledUpdated$: jest.fn(),
timefilter: {
disableTimeRangeSelector: jest.fn(),
disableAutoRefreshSelector: jest.fn(),
enableTimeRangeSelector: jest.fn(),
enableAutoRefreshSelector: jest.fn(),
getRefreshInterval: jest.fn(),
setRefreshInterval: jest.fn(),
getTime: jest.fn(),
isAutoRefreshSelectorEnabled: jest.fn(),
isTimeRangeSelectorEnabled: jest.fn(),
getRefreshIntervalUpdate$: jest.fn(),
getTimeUpdate$: jest.fn(() => {
return of();
}),
getEnabledUpdated$: jest.fn(),
},
history: { get: jest.fn() },
},
history: { get: jest.fn() },
},
},
notifications: {
toasts: {
addDanger: () => {},
},
},
},
notifications: {
toasts: {
addDanger: () => {},
},
},
},
};
},
}));
};
},
};
});
jest.mock('../../util/dependency_cache', () => ({
getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }),

View file

@ -5,26 +5,40 @@
*/
import { useObservable } from 'react-use';
import { merge, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { merge } from 'rxjs';
import { map, skip } from 'rxjs/operators';
import { useMemo } from 'react';
import { annotationsRefresh$ } from '../services/annotations_service';
import {
mlTimefilterRefresh$,
mlTimefilterTimeChange$,
} from '../services/timefilter_refresh_service';
import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
import { useTimefilter } from '../contexts/kibana';
export interface Refresh {
lastRefresh: number;
timeRange?: { start: string; end: string };
}
const refresh$: Observable<Refresh> = merge(
mlTimefilterRefresh$,
mlTimefilterTimeChange$,
annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d })))
);
/**
* Hook that provides the latest refresh timestamp
* and the most recent applied time range.
*/
export const useRefresh = () => {
const timefilter = useTimefilter();
const refresh$ = useMemo(() => {
return merge(
mlTimefilterRefresh$,
timefilter.getTimeUpdate$().pipe(
// skip initially emitted value
skip(1),
map((_) => {
const { from, to } = timefilter.getTime();
return { lastRefresh: Date.now(), timeRange: { start: from, end: to } };
})
),
annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d })))
);
}, []);
return useObservable<Refresh>(refresh$);
};

View file

@ -12,14 +12,19 @@ import {
UI_SETTINGS,
} from '../../../../../../src/plugins/data/public';
import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets';
import { ExplorerJob, OverallSwimlaneData, SwimlaneData } from '../explorer/explorer_utils';
import {
ExplorerJob,
OverallSwimlaneData,
SwimlaneData,
ViewBySwimLaneData,
} from '../explorer/explorer_utils';
import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants';
import { MlResultsService } from './results_service';
/**
* Anomaly Explorer Service
* Service for retrieving anomaly swim lanes data.
*/
export class ExplorerService {
export class AnomalyTimelineService {
private timeBuckets: TimeBuckets;
private _customTimeRange: TimeRange | undefined;
@ -130,12 +135,27 @@ export class ExplorerService {
return overallSwimlaneData;
}
/**
* Fetches view by swim lane data.
*
* @param fieldValues
* @param bounds
* @param selectedJobs
* @param viewBySwimlaneFieldName
* @param swimlaneLimit
* @param perPage
* @param fromPage
* @param swimlaneContainerWidth
* @param influencersFilterQuery
*/
public async loadViewBySwimlane(
fieldValues: string[],
bounds: { earliest: number; latest: number },
selectedJobs: ExplorerJob[],
viewBySwimlaneFieldName: string,
swimlaneLimit: number,
perPage: number,
fromPage: number,
swimlaneContainerWidth: number,
influencersFilterQuery?: any
): Promise<SwimlaneData | undefined> {
@ -172,7 +192,8 @@ export class ExplorerService {
searchBounds.min.valueOf(),
searchBounds.max.valueOf(),
interval,
swimlaneLimit
perPage,
fromPage
);
} else {
response = await this.mlResultsService.getInfluencerValueMaxScoreByTime(
@ -183,6 +204,8 @@ export class ExplorerService {
searchBounds.max.valueOf(),
interval,
swimlaneLimit,
perPage,
fromPage,
influencersFilterQuery
);
}
@ -193,6 +216,7 @@ export class ExplorerService {
const viewBySwimlaneData = this.processViewByResults(
response.results,
response.cardinality,
fieldValues,
bounds,
viewBySwimlaneFieldName,
@ -204,6 +228,55 @@ export class ExplorerService {
return viewBySwimlaneData;
}
public async loadViewByTopFieldValuesForSelectedTime(
earliestMs: number,
latestMs: number,
selectedJobs: ExplorerJob[],
viewBySwimlaneFieldName: string,
swimlaneLimit: number,
perPage: number,
fromPage: number,
swimlaneContainerWidth: number
) {
const selectedJobIds = selectedJobs.map((d) => d.id);
// Find the top field values for the selected time, and then load the 'view by'
// swimlane over the full time range for those specific field values.
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
const resp = await this.mlResultsService.getTopInfluencers(
selectedJobIds,
earliestMs,
latestMs,
swimlaneLimit,
perPage,
fromPage
);
if (resp.influencers[viewBySwimlaneFieldName] === undefined) {
return [];
}
const topFieldValues: any[] = [];
const topInfluencers = resp.influencers[viewBySwimlaneFieldName];
if (Array.isArray(topInfluencers)) {
topInfluencers.forEach((influencerData) => {
if (influencerData.maxAnomalyScore > 0) {
topFieldValues.push(influencerData.influencerFieldValue);
}
});
}
return topFieldValues;
} else {
const resp = await this.mlResultsService.getScoresByBucket(
selectedJobIds,
earliestMs,
latestMs,
this.getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth).asSeconds() + 's',
swimlaneLimit
);
return Object.keys(resp.results);
}
}
private getTimeBounds(): TimeRangeBounds {
return this._customTimeRange !== undefined
? this.timeFilter.calculateBounds(this._customTimeRange)
@ -245,6 +318,7 @@ export class ExplorerService {
private processViewByResults(
scoresByInfluencerAndTime: Record<string, { [timeMs: number]: number }>,
cardinality: number,
sortedLaneValues: string[],
bounds: any,
viewBySwimlaneFieldName: string,
@ -254,7 +328,7 @@ export class ExplorerService {
// 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 = {
const dataset: ViewBySwimLaneData = {
fieldName: viewBySwimlaneFieldName,
points: [],
laneLabels: [],
@ -262,6 +336,7 @@ export class ExplorerService {
// Set the earliest and latest to be the same as the overall swim lane.
earliest: bounds.earliest,
latest: bounds.latest,
cardinality,
};
const maxScoreByLaneLabel: Record<string, number> = {};

View file

@ -37,7 +37,7 @@ describe('DashboardService', () => {
// assert
expect(mockSavedObjectClient.find).toHaveBeenCalledWith({
type: 'dashboard',
perPage: 10,
perPage: 1000,
search: `test*`,
searchFields: ['title^3', 'description'],
});

View file

@ -34,7 +34,7 @@ export function dashboardServiceProvider(
async fetchDashboards(query?: string) {
return await savedObjectClient.find<SavedObjectDashboard>({
type: 'dashboard',
perPage: 10,
perPage: 1000,
search: query ? `${query}*` : '',
searchFields: ['title^3', 'description'],
});

View file

@ -12,7 +12,7 @@ import { annotations } from './annotations';
import { dataFrameAnalytics } from './data_frame_analytics';
import { filters } from './filters';
import { resultsApiProvider } from './results';
import { jobs } from './jobs';
import { jobsApiProvider } from './jobs';
import { fileDatavisualizer } from './datavisualizer';
import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info';
@ -726,7 +726,7 @@ export function mlApiServicesProvider(httpService: HttpService) {
dataFrameAnalytics,
filters,
results: resultsApiProvider(httpService),
jobs,
jobs: jobsApiProvider(httpService),
fileDatavisualizer,
};
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { http } from '../http_service';
import { HttpService } from '../http_service';
import { basePath } from './index';
import { Dictionary } from '../../../../common/types/common';
@ -24,10 +24,10 @@ import {
import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job';
import { Category } from '../../../../common/types/categories';
export const jobs = {
export const jobsApiProvider = (httpService: HttpService) => ({
jobsSummary(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<MlSummaryJobs>({
return httpService.http<MlSummaryJobs>({
path: `${basePath()}/jobs/jobs_summary`,
method: 'POST',
body,
@ -36,7 +36,10 @@ export const jobs = {
jobsWithTimerange(dateFormatTz: string) {
const body = JSON.stringify({ dateFormatTz });
return http<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary<MlJobWithTimeRange> }>({
return httpService.http<{
jobs: MlJobWithTimeRange[];
jobsMap: Dictionary<MlJobWithTimeRange>;
}>({
path: `${basePath()}/jobs/jobs_with_time_range`,
method: 'POST',
body,
@ -45,7 +48,7 @@ export const jobs = {
jobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<CombinedJobWithStats[]>({
return httpService.http<CombinedJobWithStats[]>({
path: `${basePath()}/jobs/jobs`,
method: 'POST',
body,
@ -53,7 +56,7 @@ export const jobs = {
},
groups() {
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/groups`,
method: 'GET',
});
@ -61,7 +64,7 @@ export const jobs = {
updateGroups(updatedJobs: string[]) {
const body = JSON.stringify({ jobs: updatedJobs });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/update_groups`,
method: 'POST',
body,
@ -75,7 +78,7 @@ export const jobs = {
end,
});
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/force_start_datafeeds`,
method: 'POST',
body,
@ -84,7 +87,7 @@ export const jobs = {
stopDatafeeds(datafeedIds: string[]) {
const body = JSON.stringify({ datafeedIds });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/stop_datafeeds`,
method: 'POST',
body,
@ -93,7 +96,7 @@ export const jobs = {
deleteJobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/delete_jobs`,
method: 'POST',
body,
@ -102,7 +105,7 @@ export const jobs = {
closeJobs(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/close_jobs`,
method: 'POST',
body,
@ -111,7 +114,7 @@ export const jobs = {
forceStopAndCloseJob(jobId: string) {
const body = JSON.stringify({ jobId });
return http<{ success: boolean }>({
return httpService.http<{ success: boolean }>({
path: `${basePath()}/jobs/force_stop_and_close_job`,
method: 'POST',
body,
@ -121,7 +124,7 @@ export const jobs = {
jobAuditMessages(jobId: string, from?: number) {
const jobIdString = jobId !== undefined ? `/${jobId}` : '';
const query = from !== undefined ? { from } : {};
return http<JobMessage[]>({
return httpService.http<JobMessage[]>({
path: `${basePath()}/job_audit_messages/messages${jobIdString}`,
method: 'GET',
query,
@ -129,7 +132,7 @@ export const jobs = {
},
deletingJobTasks() {
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/deleting_jobs_tasks`,
method: 'GET',
});
@ -137,7 +140,7 @@ export const jobs = {
jobsExist(jobIds: string[]) {
const body = JSON.stringify({ jobIds });
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/jobs_exist`,
method: 'POST',
body,
@ -146,7 +149,7 @@ export const jobs = {
newJobCaps(indexPatternTitle: string, isRollup: boolean = false) {
const query = isRollup === true ? { rollup: true } : {};
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}`,
method: 'GET',
query,
@ -175,7 +178,7 @@ export const jobs = {
splitFieldName,
splitFieldValue,
});
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/new_job_line_chart`,
method: 'POST',
body,
@ -202,7 +205,7 @@ export const jobs = {
aggFieldNamePairs,
splitFieldName,
});
return http<any>({
return httpService.http<any>({
path: `${basePath()}/jobs/new_job_population_chart`,
method: 'POST',
body,
@ -210,7 +213,7 @@ export const jobs = {
},
getAllJobAndGroupIds() {
return http<ExistingJobsAndGroups>({
return httpService.http<ExistingJobsAndGroups>({
path: `${basePath()}/jobs/all_jobs_and_group_ids`,
method: 'GET',
});
@ -222,7 +225,7 @@ export const jobs = {
start,
end,
});
return http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({
return httpService.http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({
path: `${basePath()}/jobs/look_back_progress`,
method: 'POST',
body,
@ -249,7 +252,7 @@ export const jobs = {
end,
analyzer,
});
return http<{
return httpService.http<{
examples: CategoryFieldExample[];
sampleSize: number;
overallValidStatus: CATEGORY_EXAMPLES_VALIDATION_STATUS;
@ -263,7 +266,10 @@ export const jobs = {
topCategories(jobId: string, count: number) {
const body = JSON.stringify({ jobId, count });
return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
return httpService.http<{
total: number;
categories: Array<{ count?: number; category: Category }>;
}>({
path: `${basePath()}/jobs/top_categories`,
method: 'POST',
body,
@ -278,10 +284,13 @@ export const jobs = {
calendarEvents?: Array<{ start: number; end: number; description: string }>
) {
const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents });
return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
return httpService.http<{
total: number;
categories: Array<{ count?: number; category: Category }>;
}>({
path: `${basePath()}/jobs/revert_model_snapshot`,
method: 'POST',
body,
});
},
};
});

View file

@ -14,9 +14,19 @@ export function resultsServiceProvider(
earliestMs: number,
latestMs: number,
interval: string | number,
maxResults: number
perPage?: number,
fromPage?: number
): Promise<any>;
getTopInfluencers(
selectedJobIds: string[],
earliestMs: number,
latestMs: number,
maxFieldValues: number,
perPage?: number,
fromPage?: number,
influencers?: any[],
influencersFilterQuery?: any
): Promise<any>;
getTopInfluencers(): Promise<any>;
getTopInfluencerValues(): Promise<any>;
getOverallBucketScores(
jobIds: any,
@ -33,6 +43,8 @@ export function resultsServiceProvider(
latestMs: number,
interval: string,
maxResults: number,
perPage: number,
fromPage: number,
influencersFilterQuery: any
): Promise<any>;
getRecordInfluencers(): Promise<any>;

View file

@ -9,6 +9,10 @@ import _ from 'lodash';
import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils';
import { escapeForElasticsearchQuery } from '../../util/string_utils';
import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns';
import {
ANOMALY_SWIM_LANE_HARD_LIMIT,
SWIM_LANE_DEFAULT_PAGE_SIZE,
} from '../../explorer/explorer_constants';
/**
* Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards.
@ -24,7 +28,7 @@ export function resultsServiceProvider(mlApiServices) {
// Pass an empty array or ['*'] to search over all job IDs.
// Returned response contains a results property, with a key for job
// which has results for the specified time range.
getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) {
getScoresByBucket(jobIds, earliestMs, latestMs, interval, perPage = 10, fromPage = 1) {
return new Promise((resolve, reject) => {
const obj = {
success: true,
@ -88,7 +92,7 @@ export function resultsServiceProvider(mlApiServices) {
jobId: {
terms: {
field: 'job_id',
size: maxResults !== undefined ? maxResults : 5,
size: jobIds?.length ?? 1,
order: {
anomalyScore: 'desc',
},
@ -99,6 +103,12 @@ export function resultsServiceProvider(mlApiServices) {
field: 'anomaly_score',
},
},
bucketTruncate: {
bucket_sort: {
from: (fromPage - 1) * perPage,
size: perPage === 0 ? 1 : perPage,
},
},
byTime: {
date_histogram: {
field: 'timestamp',
@ -158,7 +168,9 @@ export function resultsServiceProvider(mlApiServices) {
jobIds,
earliestMs,
latestMs,
maxFieldValues = 10,
maxFieldValues = ANOMALY_SWIM_LANE_HARD_LIMIT,
perPage = 10,
fromPage = 1,
influencers = [],
influencersFilterQuery
) {
@ -272,6 +284,12 @@ export function resultsServiceProvider(mlApiServices) {
},
},
aggs: {
bucketTruncate: {
bucket_sort: {
from: (fromPage - 1) * perPage,
size: perPage,
},
},
maxAnomalyScore: {
max: {
field: 'influencer_score',
@ -472,7 +490,9 @@ export function resultsServiceProvider(mlApiServices) {
earliestMs,
latestMs,
interval,
maxResults,
maxResults = ANOMALY_SWIM_LANE_HARD_LIMIT,
perPage = SWIM_LANE_DEFAULT_PAGE_SIZE,
fromPage = 1,
influencersFilterQuery
) {
return new Promise((resolve, reject) => {
@ -565,10 +585,15 @@ export function resultsServiceProvider(mlApiServices) {
},
},
aggs: {
influencerValuesCardinality: {
cardinality: {
field: 'influencer_field_value',
},
},
influencerFieldValues: {
terms: {
field: 'influencer_field_value',
size: maxResults !== undefined ? maxResults : 10,
size: !!maxResults ? maxResults : ANOMALY_SWIM_LANE_HARD_LIMIT,
order: {
maxAnomalyScore: 'desc',
},
@ -579,6 +604,12 @@ export function resultsServiceProvider(mlApiServices) {
field: 'influencer_score',
},
},
bucketTruncate: {
bucket_sort: {
from: (fromPage - 1) * perPage,
size: perPage,
},
},
byTime: {
date_histogram: {
field: 'timestamp',
@ -618,6 +649,8 @@ export function resultsServiceProvider(mlApiServices) {
obj.results[fieldValue] = fieldValues;
});
obj.cardinality = resp.aggregations?.influencerValuesCardinality?.value ?? 0;
resolve(obj);
})
.catch((resp) => {

View file

@ -9,4 +9,3 @@ import { Subject } from 'rxjs';
import { Refresh } from '../routing/use_refresh';
export const mlTimefilterRefresh$ = new Subject<Required<Refresh>>();
export const mlTimefilterTimeChange$ = new Subject<Required<Refresh>>();

View file

@ -16,10 +16,10 @@ import {
IContainer,
} from '../../../../../../src/plugins/embeddable/public';
import { MlStartDependencies } from '../../plugin';
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_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 { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service';
import {
Filter,
Query,
@ -40,7 +40,7 @@ export interface AnomalySwimlaneEmbeddableCustomInput {
jobIds: JobId[];
swimlaneType: SwimlaneType;
viewBy?: string;
limit?: number;
perPage?: number;
// Embeddable inputs which are not included in the default interface
filters: Filter[];
@ -58,12 +58,12 @@ export interface AnomalySwimlaneEmbeddableCustomOutput {
jobIds: JobId[];
swimlaneType: SwimlaneType;
viewBy?: string;
limit?: number;
perPage?: number;
}
export interface AnomalySwimlaneServices {
anomalyDetectorService: AnomalyDetectorService;
explorerService: ExplorerService;
anomalyTimelineService: AnomalyTimelineService;
}
export type AnomalySwimlaneEmbeddableServices = [
@ -101,14 +101,20 @@ export class AnomalySwimlaneEmbeddable extends Embeddable<
super.render(node);
this.node = node;
const I18nContext = this.services[0].i18n.Context;
ReactDOM.render(
<ExplorerSwimlaneContainer
id={this.input.id}
embeddableInput={this.getInput$()}
services={this.services}
refresh={this.reload$.asObservable()}
onOutputChange={(output) => this.updateOutput(output)}
/>,
<I18nContext>
<EmbeddableSwimLaneContainer
id={this.input.id}
embeddableInput={this.getInput$()}
services={this.services}
refresh={this.reload$.asObservable()}
onInputChange={(input) => {
this.updateInput(input);
}}
/>
</I18nContext>,
node
);
}

View file

@ -46,6 +46,9 @@ describe('AnomalySwimlaneEmbeddableFactory', () => {
});
expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart));
expect(createServices[1]).toMatchObject(pluginsStart);
expect(Object.keys(createServices[2])).toEqual(['anomalyDetectorService', 'explorerService']);
expect(Object.keys(createServices[2])).toEqual([
'anomalyDetectorService',
'anomalyTimelineService',
]);
});
});

View file

@ -22,7 +22,7 @@ import {
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 { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service';
import { mlResultsServiceProvider } from '../../application/services/results_service';
import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout';
import { mlApiServicesProvider } from '../../application/services/ml_api_service';
@ -44,14 +44,10 @@ export class AnomalySwimlaneEmbeddableFactory
}
public async getExplicitInput(): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
const [{ overlays, uiSettings }, , { anomalyDetectorService }] = await this.getServices();
const [coreStart] = await this.getServices();
try {
return await resolveAnomalySwimlaneUserInput({
anomalyDetectorService,
overlays,
uiSettings,
});
return await resolveAnomalySwimlaneUserInput(coreStart);
} catch (e) {
return Promise.reject();
}
@ -62,13 +58,13 @@ export class AnomalySwimlaneEmbeddableFactory
const httpService = new HttpService(coreStart.http);
const anomalyDetectorService = new AnomalyDetectorService(httpService);
const explorerService = new ExplorerService(
const anomalyTimelineService = new AnomalyTimelineService(
pluginsStart.data.query.timefilter.timefilter,
coreStart.uiSettings,
mlResultsServiceProvider(mlApiServicesProvider(httpService))
);
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];
return [coreStart, pluginsStart, { anomalyDetectorService, anomalyTimelineService }];
}
public async create(

View file

@ -27,7 +27,7 @@ export interface AnomalySwimlaneInitializerProps {
defaultTitle: string;
influencers: string[];
initialInput?: Partial<
Pick<AnomalySwimlaneEmbeddableInput, 'jobIds' | 'swimlaneType' | 'viewBy' | 'limit'>
Pick<AnomalySwimlaneEmbeddableInput, 'jobIds' | 'swimlaneType' | 'viewBy' | 'perPage'>
>;
onCreate: (swimlaneProps: {
panelTitle: string;
@ -38,11 +38,6 @@ export interface AnomalySwimlaneInitializerProps {
onCancel: () => void;
}
const limitOptions = [5, 10, 25, 50].map((limit) => ({
value: limit,
text: `${limit}`,
}));
export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = ({
defaultTitle,
influencers,
@ -55,7 +50,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL
);
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
const swimlaneTypeOptions = [
{
@ -154,19 +148,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
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>
@ -186,7 +167,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
panelTitle,
swimlaneType,
viewBy: viewBySwimlaneFieldName,
limit,
})}
fill
>

View file

@ -5,10 +5,13 @@
*/
import React from 'react';
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
import { CoreStart } from 'kibana/public';
import moment from 'moment';
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
import {
KibanaContextProvider,
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';
@ -17,19 +20,17 @@ import {
AnomalySwimlaneEmbeddableInput,
getDefaultPanelTitle,
} from './anomaly_swimlane_embeddable';
import { getMlGlobalServices } from '../../application/app';
import { HttpService } from '../../application/services/http_service';
export async function resolveAnomalySwimlaneUserInput(
{
overlays,
anomalyDetectorService,
uiSettings,
}: {
anomalyDetectorService: AnomalyDetectorService;
overlays: OverlayStart;
uiSettings: IUiSettingsClient;
},
coreStart: CoreStart,
input?: AnomalySwimlaneEmbeddableInput
): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
const { http, uiSettings, overlays } = coreStart;
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
return new Promise(async (resolve, reject) => {
const maps = {
groupsMap: getInitialGroupsMap([]),
@ -41,48 +42,50 @@ export async function resolveAnomalySwimlaneUserInput(
const selectedIds = input?.jobIds;
const flyoutSession = overlays.openFlyout(
const flyoutSession = coreStart.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 ?? getDefaultPanelTitle(jobIds);
<KibanaContextProvider services={{ ...coreStart, mlServices: getMlGlobalServices(http) }}>
<JobSelectorFlyout
selectedIds={selectedIds}
withTimeRangeSelector={false}
dateFormatTz={dateFormatTz}
singleSelection={false}
timeseriesOnly={true}
onFlyoutClose={() => {
flyoutSession.close();
reject();
}}
onSelectionConfirmed={async ({ jobIds, groups }) => {
const title = input?.title ?? getDefaultPanelTitle(jobIds);
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
const influencers = anomalyDetectorService.extractInfluencers(jobs);
influencers.push(VIEW_BY_JOB_LABEL);
const influencers = anomalyDetectorService.extractInfluencers(jobs);
influencers.push(VIEW_BY_JOB_LABEL);
await flyoutSession.close();
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}
/>
const modalSession = overlays.openModal(
toMountPoint(
<AnomalySwimlaneInitializer
defaultTitle={title}
influencers={influencers}
initialInput={input}
onCreate={({ panelTitle, viewBy, swimlaneType }) => {
modalSession.close();
resolve({ jobIds, title: panelTitle, swimlaneType, viewBy });
}}
onCancel={() => {
modalSession.close();
reject();
}}
/>
)
);
}}
maps={maps}
/>
</KibanaContextProvider>
),
{
'data-test-subj': 'mlAnomalySwimlaneEmbeddable',

View file

@ -6,7 +6,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container';
import { BehaviorSubject, Observable } from 'rxjs';
import { I18nProvider } from '@kbn/i18n/react';
import {
@ -17,6 +17,7 @@ import { CoreStart } from 'kibana/public';
import { MlStartDependencies } from '../../plugin';
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { SwimlaneContainer } from '../../application/explorer/swimlane_container';
jest.mock('./swimlane_input_resolver', () => ({
useSwimlaneInputResolver: jest.fn(() => {
@ -24,12 +25,11 @@ jest.mock('./swimlane_input_resolver', () => ({
}),
}));
jest.mock('../../application/explorer/explorer_swimlane', () => ({
ExplorerSwimlane: jest.fn(),
}));
jest.mock('../../application/components/chart_tooltip', () => ({
MlTooltipComponent: jest.fn(),
jest.mock('../../application/explorer/swimlane_container', () => ({
SwimlaneContainer: jest.fn(() => {
return null;
}),
isViewBySwimLaneData: jest.fn(),
}));
const defaultOptions = { wrapper: I18nProvider };
@ -38,6 +38,7 @@ describe('ExplorerSwimlaneContainer', () => {
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: BehaviorSubject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
const onInputChange = jest.fn();
beforeEach(() => {
embeddableInput = new BehaviorSubject({
@ -61,25 +62,39 @@ describe('ExplorerSwimlaneContainer', () => {
};
(useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([
mockOverallData,
SWIMLANE_TYPE.OVERALL,
undefined,
mockOverallData,
10,
jest.fn(),
{},
false,
null,
]);
const { findByTestId } = render(
<ExplorerSwimlaneContainer
render(
<EmbeddableSwimLaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
onInputChange={onInputChange}
/>,
defaultOptions
);
expect(
await findByTestId('mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
).toBeDefined();
const calledWith = ((SwimlaneContainer as unknown) as jest.Mock<typeof SwimlaneContainer>).mock
.calls[0][0];
expect(calledWith).toMatchObject({
perPage: 10,
swimlaneType: SWIMLANE_TYPE.OVERALL,
swimlaneData: mockOverallData,
isLoading: false,
swimlaneLimit: undefined,
fromPage: 1,
});
});
test('should render an error in case it could not fetch the ML swimlane data', async () => {
@ -87,38 +102,25 @@ describe('ExplorerSwimlaneContainer', () => {
undefined,
undefined,
undefined,
undefined,
undefined,
false,
{ message: 'Something went wrong' },
]);
const { findByText } = render(
<ExplorerSwimlaneContainer
<EmbeddableSwimLaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
onInputChange={onInputChange}
/>,
defaultOptions
);
const errorMessage = await findByText('Something went wrong');
expect(errorMessage).toBeDefined();
});
test('should render a loading indicator during the data fetching', async () => {
const { findByTestId } = render(
<ExplorerSwimlaneContainer
id={'test-swimlane-embeddable'}
embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
}
services={services}
refresh={refresh}
/>,
defaultOptions
);
expect(
await findByTestId('loading_mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
).toBeDefined();
});
});

View file

@ -0,0 +1,113 @@
/*
* 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 } from '@elastic/eui';
import { Observable } from 'rxjs';
import { CoreStart } from 'kibana/public';
import { FormattedMessage } from '@kbn/i18n/react';
import { MlStartDependencies } from '../../plugin';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
import { SwimlaneType } from '../../application/explorer/explorer_constants';
import {
isViewBySwimLaneData,
SwimlaneContainer,
} from '../../application/explorer/swimlane_container';
export interface ExplorerSwimlaneContainerProps {
id: string;
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>;
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
refresh: Observable<any>;
onInputChange: (output: Partial<AnomalySwimlaneEmbeddableOutput>) => void;
}
export const EmbeddableSwimLaneContainer: FC<ExplorerSwimlaneContainerProps> = ({
id,
embeddableInput,
services,
refresh,
onInputChange,
}) => {
const [chartWidth, setChartWidth] = useState<number>(0);
const [fromPage, setFromPage] = useState<number>(1);
const [
swimlaneType,
swimlaneData,
perPage,
setPerPage,
timeBuckets,
isLoading,
error,
] = useSwimlaneInputResolver(
embeddableInput,
onInputChange,
refresh,
services,
chartWidth,
fromPage
);
if (error) {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.errorMessage"
defaultMessage="Unable to load the ML swim lane data"
/>
}
color="danger"
iconType="alert"
style={{ width: '100%' }}
>
<p>{error.message}</p>
</EuiCallOut>
);
}
return (
<div
style={{ width: '100%', padding: '8px' }}
data-test-subj="mlAnomalySwimlaneEmbeddableWrapper"
>
<SwimlaneContainer
timeBuckets={timeBuckets}
swimlaneData={swimlaneData!}
swimlaneType={swimlaneType as SwimlaneType}
fromPage={fromPage}
perPage={perPage}
swimlaneLimit={isViewBySwimLaneData(swimlaneData) ? swimlaneData.cardinality : undefined}
onResize={(width) => {
setChartWidth(width);
}}
onPaginationChange={(update) => {
if (update.fromPage) {
setFromPage(update.fromPage);
}
if (update.perPage) {
setFromPage(1);
setPerPage(update.perPage);
}
}}
isLoading={isLoading}
noDataWarning={
<FormattedMessage
id="xpack.ml.swimlaneEmbeddable.noDataFound"
defaultMessage="No anomalies found"
/>
}
/>
</div>
);
};

View file

@ -1,126 +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, 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';
import { SwimlaneType } from '../../application/explorer/explorer_constants';
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 = useCallback(
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 swim lane 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" data-test-subj="mlAnomalySwimlaneEmbeddableWrapper">
<MlTooltipComponent>
{(tooltipService) => (
<ExplorerSwimlane
chartWidth={chartWidth}
timeBuckets={timeBuckets}
swimlaneData={swimlaneData}
swimlaneType={swimlaneType as SwimlaneType}
tooltipService={tooltipService}
/>
)}
</MlTooltipComponent>
</EuiText>
) : (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingChart
size="xl"
data-test-subj={`loading_mlMaxAnomalyScoreEmbeddable_${id}`}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</div>
</div>
)}
</EuiResizeObserver>
);
};

View file

@ -19,6 +19,7 @@ describe('useSwimlaneInputResolver', () => {
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: Subject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
let onInputChange: jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
@ -41,7 +42,7 @@ describe('useSwimlaneInputResolver', () => {
} as CoreStart,
(null as unknown) as MlStartDependencies,
({
explorerService: {
anomalyTimelineService: {
setTimeRange: jest.fn(),
loadOverallData: jest.fn(() =>
Promise.resolve({
@ -69,6 +70,7 @@ describe('useSwimlaneInputResolver', () => {
},
} as unknown) as AnomalySwimlaneServices,
];
onInputChange = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
@ -79,9 +81,11 @@ describe('useSwimlaneInputResolver', () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSwimlaneInputResolver(
embeddableInput as Observable<AnomalySwimlaneEmbeddableInput>,
onInputChange,
refresh,
services,
1000
1000,
1
)
);
@ -94,7 +98,7 @@ describe('useSwimlaneInputResolver', () => {
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(1);
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(1);
await act(async () => {
embeddableInput.next({
@ -109,7 +113,7 @@ describe('useSwimlaneInputResolver', () => {
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(2);
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(2);
await act(async () => {
embeddableInput.next({
@ -124,7 +128,7 @@ describe('useSwimlaneInputResolver', () => {
});
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(3);
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(3);
});
});

View file

@ -16,23 +16,31 @@ import {
skipWhile,
startWith,
switchMap,
tap,
} from 'rxjs/operators';
import { CoreStart } from 'kibana/public';
import { TimeBuckets } from '../../application/util/time_buckets';
import {
AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput,
AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable';
import { MlStartDependencies } from '../../plugin';
import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants';
import {
ANOMALY_SWIM_LANE_HARD_LIMIT,
SWIM_LANE_DEFAULT_PAGE_SIZE,
SWIMLANE_TYPE,
SwimlaneType,
} 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, UI_SETTINGS } 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';
import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container';
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
const RESIZE_IGNORED_DIFF_PX = 20;
const FETCH_RESULTS_DEBOUNCE_MS = 500;
function getJobsObservable(
@ -48,17 +56,31 @@ function getJobsObservable(
export function useSwimlaneInputResolver(
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>,
onInputChange: (output: Partial<AnomalySwimlaneEmbeddableOutput>) => void,
refresh: Observable<any>,
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices],
chartWidth: number
): [string | undefined, OverallSwimlaneData | undefined, TimeBuckets, Error | null | undefined] {
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
chartWidth: number,
fromPage: number
): [
string | undefined,
OverallSwimlaneData | undefined,
number,
(perPage: number) => void,
TimeBuckets,
boolean,
Error | null | undefined
] {
const [{ uiSettings }, , { anomalyTimelineService, anomalyDetectorService }] = services;
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>();
const [error, setError] = useState<Error | null>();
const [perPage, setPerPage] = useState<number | undefined>();
const [isLoading, setIsLoading] = useState(false);
const chartWidth$ = useMemo(() => new Subject<number>(), []);
const fromPage$ = useMemo(() => new Subject<number>(), []);
const perPage$ = useMemo(() => new Subject<number>(), []);
const timeBuckets = useMemo(() => {
return new TimeBuckets({
@ -73,28 +95,32 @@ export function useSwimlaneInputResolver(
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;
})
chartWidth$.pipe(skipWhile((v) => !v)),
fromPage$,
perPage$.pipe(
startWith(undefined),
// no need to emit when the initial value has been set
distinctUntilChanged(
(prev, curr) => prev === undefined && curr === SWIM_LANE_DEFAULT_PAGE_SIZE
)
),
refresh.pipe(startWith(null)),
])
.pipe(
tap(setIsLoading.bind(null, true)),
debounceTime(FETCH_RESULTS_DEBOUNCE_MS),
switchMap(([jobs, input, swimlaneContainerWidth]) => {
switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => {
const {
viewBy,
swimlaneType: swimlaneTypeInput,
limit,
perPage: perPageInput,
timeRange,
filters,
query,
viewMode,
} = input;
explorerService.setTimeRange(timeRange);
anomalyTimelineService.setTimeRange(timeRange);
if (!swimlaneType) {
setSwimlaneType(swimlaneTypeInput);
@ -118,18 +144,34 @@ export function useSwimlaneInputResolver(
return of(undefined);
}
return from(explorerService.loadOverallData(explorerJobs, swimlaneContainerWidth)).pipe(
return from(
anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth)
).pipe(
switchMap((overallSwimlaneData) => {
const { earliest, latest } = overallSwimlaneData;
if (overallSwimlaneData && swimlaneTypeInput === SWIMLANE_TYPE.VIEW_BY) {
if (perPageFromState === undefined) {
// set initial pagination from the input or default one
setPerPage(perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE);
}
if (viewMode === ViewMode.EDIT && perPageFromState !== perPageInput) {
// store per page value when the dashboard is in the edit mode
onInputChange({ perPage: perPageFromState });
}
return from(
explorerService.loadViewBySwimlane(
anomalyTimelineService.loadViewBySwimlane(
[],
{ earliest, latest },
explorerJobs,
viewBy!,
limit!,
isViewBySwimLaneData(swimlaneData)
? swimlaneData.cardinality
: ANOMALY_SWIM_LANE_HARD_LIMIT,
perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE,
fromPageInput,
swimlaneContainerWidth,
appliedFilters
)
@ -156,6 +198,7 @@ export function useSwimlaneInputResolver(
if (data !== undefined) {
setError(null);
setSwimlaneData(data);
setIsLoading(false);
}
});
@ -164,11 +207,28 @@ export function useSwimlaneInputResolver(
};
}, []);
useEffect(() => {
fromPage$.next(fromPage);
}, [fromPage]);
useEffect(() => {
if (perPage === undefined) return;
perPage$.next(perPage);
}, [perPage]);
useEffect(() => {
chartWidth$.next(chartWidth);
}, [chartWidth]);
return [swimlaneType, swimlaneData, timeBuckets, error];
return [
swimlaneType,
swimlaneData,
perPage ?? SWIM_LANE_DEFAULT_PAGE_SIZE,
setPerPage,
timeBuckets,
isLoading,
error,
];
}
export function processFilters(filters: Filter[], query: Query) {

View file

@ -14,8 +14,6 @@ import {
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';
@ -39,18 +37,10 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt
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));
const [coreStart] = await getStartServices();
try {
const result = await resolveAnomalySwimlaneUserInput(
{
anomalyDetectorService,
overlays,
uiSettings,
},
embeddable.getInput()
);
const result = await resolveAnomalySwimlaneUserInput(coreStart, embeddable.getInput());
embeddable.updateInput(result);
} catch (e) {
return Promise.reject();

View file

@ -9790,8 +9790,6 @@
"xpack.ml.explorer.jobIdLabel": "ジョブ ID",
"xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(すべての影響因子のジョブスコア)",
"xpack.ml.explorer.kueryBar.filterPlaceholder": "影響因子フィールドでフィルタリング… ({queryExample})",
"xpack.ml.explorer.limitLabel": "制限",
"xpack.ml.explorer.loadingLabel": "読み込み中",
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。",
"xpack.ml.explorer.noInfluencersFoundTitle": "{viewBySwimlaneFieldName}影響因子が見つかりません",
"xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "指定されたフィルターの{viewBySwimlaneFieldName} 影響因子が見つかりません",

View file

@ -9794,8 +9794,6 @@
"xpack.ml.explorer.jobIdLabel": "作业 ID",
"xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(所有影响因素的作业分数)",
"xpack.ml.explorer.kueryBar.filterPlaceholder": "按影响因素字段筛选……({queryExample}",
"xpack.ml.explorer.limitLabel": "限制",
"xpack.ml.explorer.loadingLabel": "正在加载",
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "“顶级影响因素”列表被隐藏,因为没有为所选作业配置影响因素。",
"xpack.ml.explorer.noInfluencersFoundTitle": "未找到任何 {viewBySwimlaneFieldName} 影响因素",
"xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "对于指定筛选找不到任何 {viewBySwimlaneFieldName} 影响因素",

View file

@ -76,7 +76,7 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid
async addAndEditSwimlaneInDashboard(dashboardTitle: string) {
await this.filterWithSearchString(dashboardTitle);
await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll');
await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll');
await testSubjects.clickWhenNotDisabled('mlDashboardSelectionTable > checkboxSelectAll');
expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be(
true
);