[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 React, { FC } from 'react';
import ReactDOM from 'react-dom'; 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'; import { Storage } from '../../../../../src/plugins/kibana_utils/public';
@ -17,6 +17,8 @@ import { setLicenseCache } from './license';
import { MlSetupDependencies, MlStartDependencies } from '../plugin'; import { MlSetupDependencies, MlStartDependencies } from '../plugin';
import { MlRouter } from './routing'; import { MlRouter } from './routing';
import { mlApiServicesProvider } from './services/ml_api_service';
import { HttpService } from './services/http_service';
type MlDependencies = MlSetupDependencies & MlStartDependencies; type MlDependencies = MlSetupDependencies & MlStartDependencies;
@ -27,6 +29,23 @@ interface AppProps {
const localStorage = new Storage(window.localStorage); 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 App: FC<AppProps> = ({ coreStart, deps }) => {
const pageDeps = { const pageDeps = {
indexPatterns: deps.data.indexPatterns, indexPatterns: deps.data.indexPatterns,
@ -47,7 +66,9 @@ const App: FC<AppProps> = ({ coreStart, deps }) => {
const I18nContext = coreStart.i18n.Context; const I18nContext = coreStart.i18n.Context;
return ( return (
<I18nContext> <I18nContext>
<KibanaContextProvider services={services}> <KibanaContextProvider
services={{ ...services, mlServices: getMlGlobalServices(coreStart.http) }}
>
<MlRouter pageDeps={pageDeps} /> <MlRouter pageDeps={pageDeps} />
</KibanaContextProvider> </KibanaContextProvider>
</I18nContext> </I18nContext>

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@
import React from 'react'; import React from 'react';
import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public';
import { SavedSearchSavedObject } from '../../../../common/types/kibana'; import { SavedSearchSavedObject } from '../../../../common/types/kibana';
import { MlServicesContext } from '../../app';
export interface MlContextValue { export interface MlContextValue {
combinedQuery: any; combinedQuery: any;
@ -34,4 +35,4 @@ export type SavedSearchQuery = object;
// Multiple custom hooks can be created to access subsets of // Multiple custom hooks can be created to access subsets of
// the overall context value if necessary too, // the overall context value if necessary too,
// see useCurrentIndexPattern() for example. // 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 { .ml-swimlane-selector {
visibility: hidden; visibility: hidden;
} }
@ -104,10 +106,9 @@
// SASSTODO: This entire selector needs to be rewritten. // SASSTODO: This entire selector needs to be rewritten.
// It looks extremely brittle with very specific sizing units // It looks extremely brittle with very specific sizing units
.ml-explorer-swimlane { .mlExplorerSwimlane {
user-select: none; user-select: none;
padding: 0; padding: 0;
margin-bottom: $euiSizeS;
line.gridLine { line.gridLine {
stroke: $euiBorderColor; stroke: $euiBorderColor;
@ -218,17 +219,20 @@
div.lane { div.lane {
height: 30px; height: 30px;
border-bottom: 0px; border-bottom: 0px;
border-radius: 2px; border-radius: $borderRadius;
margin-top: -1px;
white-space: nowrap; white-space: nowrap;
&:not(:first-child) {
margin-top: -1px;
}
div.lane-label { div.lane-label {
display: inline-block; display: inline-block;
font-size: 13px; font-size: $euiFontSizeXS;
height: 30px; height: 30px;
text-align: right; text-align: right;
vertical-align: middle; vertical-align: middle;
border-radius: 2px; border-radius: $borderRadius;
padding-right: 5px; padding-right: 5px;
margin-right: 5px; margin-right: 5px;
border: 1px solid transparent; border: 1px solid transparent;
@ -261,7 +265,7 @@
.sl-cell-inner-dragselect { .sl-cell-inner-dragselect {
height: 26px; height: 26px;
margin: 1px; margin: 1px;
border-radius: 2px; border-radius: $borderRadius;
text-align: center; text-align: center;
} }
@ -293,7 +297,7 @@
.sl-cell-inner, .sl-cell-inner,
.sl-cell-inner-dragselect { .sl-cell-inner-dragselect {
border: 2px solid $euiColorDarkShade; border: 2px solid $euiColorDarkShade;
border-radius: 2px; border-radius: $borderRadius;
opacity: 1; opacity: 1;
} }
} }

View file

@ -11,6 +11,7 @@ import useObservable from 'react-use/lib/useObservable';
import { forkJoin, of, Observable, Subject } from 'rxjs'; import { forkJoin, of, Observable, Subject } from 'rxjs';
import { mergeMap, switchMap, tap } from 'rxjs/operators'; import { mergeMap, switchMap, tap } from 'rxjs/operators';
import { useCallback, useMemo } from 'react';
import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service';
import { explorerService } from '../explorer_dashboard_service'; import { explorerService } from '../explorer_dashboard_service';
import { import {
@ -22,15 +23,17 @@ import {
loadAnomaliesTableData, loadAnomaliesTableData,
loadDataForCharts, loadDataForCharts,
loadFilteredTopInfluencers, loadFilteredTopInfluencers,
loadOverallData,
loadTopInfluencers, loadTopInfluencers,
loadViewBySwimlane,
loadViewByTopFieldValuesForSelectedTime,
AppStateSelectedCells, AppStateSelectedCells,
ExplorerJob, ExplorerJob,
TimeRangeBounds, TimeRangeBounds,
} from '../explorer_utils'; } from '../explorer_utils';
import { ExplorerState } from '../reducers'; 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. // Memoize the data fetching methods.
// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument // 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 // about this parameter. The generic type T retains and returns the type information of
// the original function. // the original function.
const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs); 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 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) => { const memoize = <T extends (...a: any[]) => any>(func: T, context?: any) => {
return memoizeOne(wrapWithLastRefreshArg<T>(func), memoizeIsEqual); return memoizeOne(wrapWithLastRefreshArg<T>(func, context), memoizeIsEqual);
}; };
const memoizedAnomalyDataChange = memoize<typeof anomalyDataChange>(anomalyDataChange); const memoizedAnomalyDataChange = memoize<typeof anomalyDataChange>(anomalyDataChange);
@ -56,9 +59,7 @@ const memoizedLoadDataForCharts = memoize<typeof loadDataForCharts>(loadDataForC
const memoizedLoadFilteredTopInfluencers = memoize<typeof loadFilteredTopInfluencers>( const memoizedLoadFilteredTopInfluencers = memoize<typeof loadFilteredTopInfluencers>(
loadFilteredTopInfluencers loadFilteredTopInfluencers
); );
const memoizedLoadOverallData = memoize(loadOverallData);
const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); const memoizedLoadTopInfluencers = memoize(loadTopInfluencers);
const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane);
const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData);
export interface LoadExplorerDataConfig { export interface LoadExplorerDataConfig {
@ -73,6 +74,9 @@ export interface LoadExplorerDataConfig {
tableInterval: string; tableInterval: string;
tableSeverity: number; tableSeverity: number;
viewBySwimlaneFieldName: string; viewBySwimlaneFieldName: string;
viewByFromPage: number;
viewByPerPage: number;
swimlaneContainerWidth: number;
} }
export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { 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. * Fetches the data necessary for the Anomaly Explorer using observables.
*
* @param config LoadExplorerDataConfig
*
* @return Partial<ExplorerState>
*/ */
function loadExplorerData(config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> { const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService) => {
if (!isLoadExplorerDataConfig(config)) { const memoizedLoadOverallData = memoize(
return of({}); anomalyTimelineService.loadOverallData,
} anomalyTimelineService
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 memoizedLoadViewBySwimlane = memoize(
anomalyTimelineService.loadViewBySwimlane,
anomalyTimelineService
);
return (config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> => {
if (!isLoadExplorerDataConfig(config)) {
return of({});
}
const dateFormatTz = getDateFormatTz(); const {
bounds,
// 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, lastRefresh,
influencersFilterQuery,
noInfluencersConfigured,
selectedCells, selectedCells,
selectedJobs, 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, swimlaneBucketInterval,
bounds swimlaneLimit,
),
tableData: memoizedLoadAnomaliesTableData(
lastRefresh,
selectedCells,
selectedJobs,
dateFormatTz,
swimlaneBucketInterval.asSeconds(),
bounds,
viewBySwimlaneFieldName,
tableInterval, tableInterval,
tableSeverity, tableSeverity,
influencersFilterQuery viewBySwimlaneFieldName,
), swimlaneContainerWidth,
topFieldValues: viewByFromPage,
selectedCells !== undefined && selectedCells.showTopFieldValues === true viewByPerPage,
? loadViewByTopFieldValuesForSelectedTime( } = 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.earliestMs,
timerange.latestMs, timerange.latestMs,
selectedJobs, tableSeverity
viewBySwimlaneFieldName, );
swimlaneLimit, } else {
noInfluencersConfigured memoizedAnomalyDataChange(
)
: 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(
lastRefresh, lastRefresh,
topFieldValues, [],
{ timerange.earliestMs,
earliest: overallState.overallSwimlaneData.earliest, timerange.latestMs,
latest: overallState.overallSwimlaneData.latest, tableSeverity
}, );
selectedJobs, }
viewBySwimlaneFieldName, }),
swimlaneLimit, // Load view-by swimlane data and filtered top influencers.
influencersFilterQuery, // mergeMap is used to have access to the already fetched data and act on it in arg #1.
noInfluencersConfigured // 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 }) =>
{ annotationsData, overallState, tableData }, forkJoin({
{ influencers, viewBySwimlaneState } influencers:
): Partial<ExplorerState> => { (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
return { anomalyChartRecords !== undefined &&
annotationsData, anomalyChartRecords.length > 0
influencers, ? memoizedLoadFilteredTopInfluencers(
...overallState, lastRefresh,
...viewBySwimlaneState, jobIds,
tableData, timerange.earliestMs,
}; timerange.latestMs,
} anomalyChartRecords,
) selectionInfluencers,
); noInfluencersConfigured,
} influencersFilterQuery
)
const loadExplorerData$ = new Subject<LoadExplorerDataConfig>(); : Promise.resolve(influencers),
const explorerData$ = loadExplorerData$.pipe( viewBySwimlaneState: memoizedLoadViewBySwimlane(
switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config)) lastRefresh,
); topFieldValues,
{
export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any) => void] => { earliest: overallState.earliest,
const explorerData = useObservable(explorerData$); latest: overallState.latest,
return [explorerData, (c) => loadExplorerData$.next(c)]; },
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 { interface AddToDashboardControlProps {
jobIds: JobId[]; jobIds: JobId[];
viewBy: string; viewBy: string;
limit: number;
onClose: (callback?: () => Promise<any>) => void; onClose: (callback?: () => Promise<any>) => void;
} }
@ -63,7 +62,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
onClose, onClose,
jobIds, jobIds,
viewBy, viewBy,
limit,
}) => { }) => {
const { const {
notifications: { toasts }, notifications: { toasts },
@ -141,7 +139,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
jobIds, jobIds,
swimlaneType, swimlaneType,
viewBy, viewBy,
limit,
}, },
}; };
} }
@ -206,8 +203,8 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
{ {
id: SWIMLANE_TYPE.VIEW_BY, id: SWIMLANE_TYPE.VIEW_BY,
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
defaultMessage: 'View by {viewByField}, up to {limit} rows', defaultMessage: 'View by {viewByField}',
values: { viewByField: viewBy, limit }, values: { viewByField: viewBy },
}), }),
}, },
]; ];

View file

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

View file

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

View file

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

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 React from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { import {
EuiFlexGroup, EuiFlexGroup,
@ -27,6 +25,7 @@ import {
EuiPageHeaderSection, EuiPageHeaderSection,
EuiSpacer, EuiSpacer,
EuiTitle, EuiTitle,
EuiLoadingContent,
} from '@elastic/eui'; } from '@elastic/eui';
import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; 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 { InfluencersList } from '../components/influencers_list';
import { explorerService } from './explorer_dashboard_service'; import { explorerService } from './explorer_dashboard_service';
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
import { NavigationMenu } from '../components/navigation_menu'; import { NavigationMenu } from '../components/navigation_menu';
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
import { JobSelector } from '../components/job_selector'; import { JobSelector } from '../components/job_selector';
import { SelectInterval } from '../components/controls/select_interval/select_interval'; 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 { SelectSeverity } from '../components/controls/select_severity/select_severity';
import { import {
ExplorerQueryBar, ExplorerQueryBar,
@ -142,19 +139,6 @@ export class Explorer extends React.Component {
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; 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 // 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 // and will cause a syntax error when called with getKqlQueryValues
applyFilter = (fieldName, fieldValue, action) => { applyFilter = (fieldName, fieldValue, action) => {
@ -240,29 +224,7 @@ export class Explorer extends React.Component {
const noJobsFound = selectedJobs === null || selectedJobs.length === 0; const noJobsFound = selectedJobs === null || selectedJobs.length === 0;
const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0;
if (loading === true) { if (noJobsFound && !loading) {
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) {
return ( return (
<ExplorerPage jobSelectorProps={jobSelectorProps}> <ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoJobsFound /> <ExplorerNoJobsFound />
@ -270,7 +232,7 @@ export class Explorer extends React.Component {
); );
} }
if (noJobsFound && hasResults === false) { if (noJobsFound && hasResults === false && !loading) {
return ( return (
<ExplorerPage jobSelectorProps={jobSelectorProps}> <ExplorerPage jobSelectorProps={jobSelectorProps}>
<ExplorerNoResultsFound /> <ExplorerNoResultsFound />
@ -320,7 +282,11 @@ export class Explorer extends React.Component {
/> />
</h2> </h2>
</EuiTitle> </EuiTitle>
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} /> {loading ? (
<EuiLoadingContent lines={10} />
) : (
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} />
)}
</div> </div>
)} )}
@ -352,59 +318,59 @@ export class Explorer extends React.Component {
</> </>
)} )}
<EuiTitle className="panel-title"> {loading === false && (
<h2> <>
<FormattedMessage <EuiTitle className="panel-title">
id="xpack.ml.explorer.anomaliesTitle" <h2>
defaultMessage="Anomalies" <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>
</div> </div>
</ExplorerPage> </ExplorerPage>

View file

@ -27,9 +27,10 @@ export const EXPLORER_ACTION = {
SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings',
SET_SELECTED_CELLS: 'setSelectedCells', SET_SELECTED_CELLS: 'setSelectedCells',
SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth',
SET_SWIMLANE_LIMIT: 'setSwimlaneLimit',
SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName',
SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading', SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading',
SET_VIEW_BY_PER_PAGE: 'setViewByPerPage',
SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage',
}; };
export const FILTER_ACTION = { export const FILTER_ACTION = {
@ -51,9 +52,23 @@ export const CHART_TYPE = {
}; };
export const MAX_CATEGORY_EXAMPLES = 10; 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_VALUES = 10;
export const MAX_INFLUENCER_FIELD_NAMES = 50; export const MAX_INFLUENCER_FIELD_NAMES = 50;
export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', {
defaultMessage: 'job ID', 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 { isEqual } from 'lodash';
import { from, isObservable, Observable, Subject } from 'rxjs'; 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'; import { DeepPartial } from '../../../common/types/common';
@ -49,7 +49,9 @@ const explorerFilteredAction$ = explorerAction$.pipe(
// applies action and returns state // applies action and returns state
const explorerState$: Observable<ExplorerState> = explorerFilteredAction$.pipe( const explorerState$: Observable<ExplorerState> = explorerFilteredAction$.pipe(
scan(explorerReducer, getExplorerDefaultState()) scan(explorerReducer, getExplorerDefaultState()),
// share the last emitted value among new subscribers
shareReplay(1)
); );
interface ExplorerAppState { interface ExplorerAppState {
@ -59,6 +61,8 @@ interface ExplorerAppState {
selectedTimes?: number[]; selectedTimes?: number[];
showTopFieldValues?: boolean; showTopFieldValues?: boolean;
viewByFieldName?: string; viewByFieldName?: string;
viewByPerPage?: number;
viewByFromPage?: number;
}; };
mlExplorerFilter: { mlExplorerFilter: {
influencersFilterQuery?: unknown; influencersFilterQuery?: unknown;
@ -88,6 +92,14 @@ const explorerAppState$: Observable<ExplorerAppState> = explorerState$.pipe(
appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; 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) { if (state.filterActive) {
appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery;
appState.mlExplorerFilter.filterActive = state.filterActive; appState.mlExplorerFilter.filterActive = state.filterActive;
@ -153,13 +165,16 @@ export const explorerService = {
payload, payload,
}); });
}, },
setSwimlaneLimit: (payload: number) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_LIMIT, payload });
},
setViewBySwimlaneFieldName: (payload: string) => { setViewBySwimlaneFieldName: (payload: string) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload }); explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload });
}, },
setViewBySwimlaneLoading: (payload: any) => { setViewBySwimlaneLoading: (payload: any) => {
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload }); 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, ChartTooltipService,
ChartTooltipValue, ChartTooltipValue,
} from '../components/chart_tooltip/chart_tooltip_service'; } from '../components/chart_tooltip/chart_tooltip_service';
import { OverallSwimlaneData } from './explorer_utils'; import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
const SCSS = { const SCSS = {
mlDragselectDragging: 'mlDragselectDragging', mlDragselectDragging: 'mlDragselectDragging',
@ -57,7 +57,7 @@ export interface ExplorerSwimlaneProps {
maskAll?: boolean; maskAll?: boolean;
timeBuckets: InstanceType<typeof TimeBucketsClass>; timeBuckets: InstanceType<typeof TimeBucketsClass>;
swimlaneCellClick?: Function; swimlaneCellClick?: Function;
swimlaneData: OverallSwimlaneData; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData;
swimlaneType: SwimlaneType; swimlaneType: SwimlaneType;
selection?: { selection?: {
lanes: any[]; lanes: any[];
@ -211,7 +211,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
const { swimlaneType } = this.props; const { swimlaneType } = this.props;
// This selects both overall and viewby swimlane // 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.selectAll('.lane-label').classed('lane-label-masked', true);
wrapper wrapper
@ -242,7 +242,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
maskIrrelevantSwimlanes(maskAll: boolean) { maskIrrelevantSwimlanes(maskAll: boolean) {
if (maskAll === true) { if (maskAll === true) {
// This selects both overall and viewby swimlane // 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('.lane-label').classed('lane-label-masked', true);
allSwimlanes allSwimlanes
.selectAll('.sl-cell-inner,.sl-cell-inner-dragselect') .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect')
@ -258,7 +258,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
clearSelection() { clearSelection() {
// This selects both overall and viewby swimlane // 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('.lane-label').classed('lane-label-masked', false);
wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-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 { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
import { TimeBucketsInterval } from '../util/time_buckets';
interface ClearedSelectedAnomaliesState { interface ClearedSelectedAnomaliesState {
selectedCells: undefined; selectedCells: undefined;
viewByLoadedForTimeFormatted: null; viewByLoadedForTimeFormatted: null;
@ -35,6 +33,10 @@ export declare interface OverallSwimlaneData extends SwimlaneData {
latest: number; latest: number;
} }
export interface ViewBySwimLaneData extends OverallSwimlaneData {
cardinality: number;
}
export declare const getDateFormatTz: () => any; export declare const getDateFormatTz: () => any;
export declare const getDefaultSwimlaneData: () => SwimlaneData; export declare const getDefaultSwimlaneData: () => SwimlaneData;
@ -163,22 +165,6 @@ declare interface LoadOverallDataResponse {
overallSwimlaneData: OverallSwimlaneData; 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: ( export declare const loadViewByTopFieldValuesForSelectedTime: (
earliestMs: number, earliestMs: number,
latestMs: number, latestMs: number,

View file

@ -8,11 +8,9 @@
* utils for Anomaly Explorer. * 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 moment from 'moment-timezone';
import { i18n } from '@kbn/i18n';
import { import {
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
ANOMALIES_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 { ml } from '../services/ml_api_service';
import { mlJobService } from '../services/job_service'; import { mlJobService } from '../services/job_service';
import { mlResultsService } from '../services/results_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 { getTimefilter, getUiSettings } from '../util/dependency_cache';
import { import {
@ -36,7 +34,6 @@ import {
SWIMLANE_TYPE, SWIMLANE_TYPE,
VIEW_BY_JOB_LABEL, VIEW_BY_JOB_LABEL,
} from './explorer_constants'; } from './explorer_constants';
import { getSwimlaneContainerWidth } from './legacy_utils';
// create new job objects based on standard job config objects // 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. // new job objects just contain job id, bucket span in seconds and a selected flag.
@ -51,6 +48,7 @@ export function getClearedSelectedAnomaliesState() {
return { return {
selectedCells: undefined, selectedCells: undefined,
viewByLoadedForTimeFormatted: null, viewByLoadedForTimeFormatted: null,
swimlaneLimit: undefined,
}; };
} }
@ -267,58 +265,6 @@ export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth)
return buckets.getInterval(); 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 // Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName
export function getViewBySwimlaneOptions({ export function getViewBySwimlaneOptions({
currentViewBySwimlaneFieldName, 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) { export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) {
const jobIds = const jobIds =
selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL 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( export async function loadTopInfluencers(
selectedJobIds, selectedJobIds,
earliestMs, earliestMs,
@ -871,6 +586,8 @@ export async function loadTopInfluencers(
earliestMs, earliestMs,
latestMs, latestMs,
MAX_INFLUENCER_FIELD_VALUES, MAX_INFLUENCER_FIELD_VALUES,
10,
1,
influencers, influencers,
influencersFilterQuery influencersFilterQuery
) )

View file

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

View file

@ -11,8 +11,3 @@ export function getChartContainerWidth() {
const chartContainer = document.querySelector('.explorer-charts'); const chartContainer = document.querySelector('.explorer-charts');
return Math.floor((chartContainer && chartContainer.clientWidth) || 0); 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: '', queryString: '',
tableQueryString: '', tableQueryString: '',
...getClearedSelectedAnomaliesState(), ...getClearedSelectedAnomaliesState(),
viewByFromPage: 1,
}; };
} }

View file

@ -17,6 +17,7 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload)
noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, noInfluencersConfigured: getInfluencers(selectedJobs).length === 0,
overallSwimlaneData: getDefaultSwimlaneData(), overallSwimlaneData: getDefaultSwimlaneData(),
selectedJobs, selectedJobs,
viewByFromPage: 1,
}; };
// clear filter if selected jobs have no influencers // 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 => { export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => {
const { type, payload } = nextAction; const { type, payload } = nextAction;
let nextState; let nextState: ExplorerState;
switch (type) { switch (type) {
case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS: case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS:
@ -39,6 +39,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...state, ...state,
...getClearedSelectedAnomaliesState(), ...getClearedSelectedAnomaliesState(),
loading: false, loading: false,
viewByFromPage: 1,
selectedJobs: [], selectedJobs: [],
}; };
break; break;
@ -82,22 +83,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
break; break;
case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH:
if (state.noInfluencersConfigured === true) { nextState = { ...state, swimlaneContainerWidth: payload };
// 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,
};
break; break;
case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME: case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME:
@ -117,6 +103,9 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
...getClearedSelectedAnomaliesState(), ...getClearedSelectedAnomaliesState(),
maskAll, maskAll,
viewBySwimlaneFieldName, viewBySwimlaneFieldName,
viewBySwimlaneData: getDefaultSwimlaneData(),
viewByFromPage: 1,
viewBySwimlaneDataLoading: true,
}; };
break; break;
@ -125,7 +114,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
nextState = { nextState = {
...state, ...state,
annotationsData, annotationsData,
...overallState, overallSwimlaneData: overallState,
tableData, tableData,
viewBySwimlaneData: { viewBySwimlaneData: {
...getDefaultSwimlaneData(), ...getDefaultSwimlaneData(),
@ -134,6 +123,22 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
}; };
break; 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: default:
nextState = state; nextState = state;
} }
@ -155,7 +160,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
filteredFields: nextState.filteredFields, filteredFields: nextState.filteredFields,
isAndOperator: nextState.isAndOperator, isAndOperator: nextState.isAndOperator,
selectedJobs: nextState.selectedJobs, selectedJobs: nextState.selectedJobs,
selectedCells: nextState.selectedCells, selectedCells: nextState.selectedCells!,
}); });
const { bounds, selectedCells } = nextState; const { bounds, selectedCells } = nextState;

View file

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

View file

@ -19,7 +19,9 @@ import {
TimeRangeBounds, TimeRangeBounds,
OverallSwimlaneData, OverallSwimlaneData,
SwimlaneData, SwimlaneData,
ViewBySwimLaneData,
} from '../../explorer_utils'; } from '../../explorer_utils';
import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants';
export interface ExplorerState { export interface ExplorerState {
annotationsData: any[]; annotationsData: any[];
@ -42,14 +44,16 @@ export interface ExplorerState {
selectedJobs: ExplorerJob[] | null; selectedJobs: ExplorerJob[] | null;
swimlaneBucketInterval: any; swimlaneBucketInterval: any;
swimlaneContainerWidth: number; swimlaneContainerWidth: number;
swimlaneLimit: number;
tableData: AnomaliesTableData; tableData: AnomaliesTableData;
tableQueryString: string; tableQueryString: string;
viewByLoadedForTimeFormatted: string | null; viewByLoadedForTimeFormatted: string | null;
viewBySwimlaneData: SwimlaneData | OverallSwimlaneData; viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData;
viewBySwimlaneDataLoading: boolean; viewBySwimlaneDataLoading: boolean;
viewBySwimlaneFieldName?: string; viewBySwimlaneFieldName?: string;
viewByPerPage: number;
viewByFromPage: number;
viewBySwimlaneOptions: string[]; viewBySwimlaneOptions: string[];
swimlaneLimit?: number;
} }
function getDefaultIndexPattern() { function getDefaultIndexPattern() {
@ -78,7 +82,6 @@ export function getExplorerDefaultState(): ExplorerState {
selectedJobs: null, selectedJobs: null,
swimlaneBucketInterval: undefined, swimlaneBucketInterval: undefined,
swimlaneContainerWidth: 0, swimlaneContainerWidth: 0,
swimlaneLimit: 10,
tableData: { tableData: {
anomalies: [], anomalies: [],
examplesByJobId: [''], examplesByJobId: [''],
@ -92,5 +95,8 @@ export function getExplorerDefaultState(): ExplorerState {
viewBySwimlaneDataLoading: false, viewBySwimlaneDataLoading: false,
viewBySwimlaneFieldName: undefined, viewBySwimlaneFieldName: undefined,
viewBySwimlaneOptions: [], 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 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 { throttle } from 'lodash';
import { import {
@ -14,48 +21,139 @@ import {
} from '../../application/explorer/explorer_swimlane'; } from '../../application/explorer/explorer_swimlane';
import { MlTooltipComponent } from '../../application/components/chart_tooltip'; 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; 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< export const SwimlaneContainer: FC<
Omit<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService'> & { Omit<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService'> & {
onResize: (width: number) => void; 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 [chartWidth, setChartWidth] = useState<number>(0);
const resizeHandler = useCallback( const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => { throttle((e: { width: number; height: number }) => {
const labelWidth = 200; const labelWidth = 200;
setChartWidth(e.width - labelWidth); const resultNewWidth = e.width - labelWidth;
onResize(e.width); if (Math.abs(resultNewWidth - chartWidth) > RESIZE_IGNORED_DIFF_PX) {
setChartWidth(resultNewWidth);
onResize(resultNewWidth);
}
}, RESIZE_THROTTLE_TIME_MS), }, 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 ( return (
<EuiResizeObserver onResize={resizeHandler}> <>
{(resizeRef) => ( <EuiResizeObserver onResize={resizeHandler}>
<div {(resizeRef) => (
ref={(el) => { <EuiFlexGroup
resizeRef(el); gutterSize={'none'}
}} direction={'column'}
> style={{ width: '100%', height: '100%', overflow: 'hidden' }}
<div style={{ width: '100%' }}> ref={(el) => {
<EuiText color="subdued" size="s"> resizeRef(el);
<MlTooltipComponent> }}
{(tooltipService) => ( data-test-subj="mlSwimLaneContainer"
<ExplorerSwimlane >
chartWidth={chartWidth} <EuiFlexItem style={{ width: '100%', overflowY: 'auto' }} grow={false}>
tooltipService={tooltipService} <EuiText color="subdued" size="s">
{...props} {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>
</EuiText> </EuiFlexItem>
</div> {isPaginationVisible && (
</div> <EuiFlexItem grow={false}>
)} <SwimLanePagination
</EuiResizeObserver> 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 { useExplorerData } from '../../explorer/actions';
import { explorerService } from '../../explorer/explorer_dashboard_service'; import { explorerService } from '../../explorer/explorer_dashboard_service';
import { getDateFormatTz } from '../../explorer/explorer_utils'; import { getDateFormatTz } from '../../explorer/explorer_utils';
import { useSwimlaneLimit } from '../../explorer/select_limit';
import { useJobSelection } from '../../components/job_selector/use_job_selection'; import { useJobSelection } from '../../components/job_selector/use_job_selection';
import { useShowCharts } from '../../components/controls/checkbox_showcharts'; import { useShowCharts } from '../../components/controls/checkbox_showcharts';
import { useTableInterval } from '../../components/controls/select_interval'; 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 { useUrlState } from '../../util/url_state';
import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs';
import { useTimefilter } from '../../contexts/kibana'; import { useTimefilter } from '../../contexts/kibana';
import { isViewBySwimLaneData } from '../../explorer/swimlane_container';
const breadcrumbs = [ const breadcrumbs = [
ML_BREADCRUMB, ML_BREADCRUMB,
@ -151,10 +151,6 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
const [showCharts] = useShowCharts(); const [showCharts] = useShowCharts();
const [tableInterval] = useTableInterval(); const [tableInterval] = useTableInterval();
const [tableSeverity] = useTableSeverity(); const [tableSeverity] = useTableSeverity();
const [swimlaneLimit] = useSwimlaneLimit();
useEffect(() => {
explorerService.setSwimlaneLimit(swimlaneLimit);
}, [swimlaneLimit]);
const [selectedCells, setSelectedCells] = useSelectedCells(); const [selectedCells, setSelectedCells] = useSelectedCells();
useEffect(() => { useEffect(() => {
@ -170,14 +166,26 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
selectedCells, selectedCells,
selectedJobs: explorerState.selectedJobs, selectedJobs: explorerState.selectedJobs,
swimlaneBucketInterval: explorerState.swimlaneBucketInterval, swimlaneBucketInterval: explorerState.swimlaneBucketInterval,
swimlaneLimit: explorerState.swimlaneLimit,
tableInterval: tableInterval.val, tableInterval: tableInterval.val,
tableSeverity: tableSeverity.val, tableSeverity: tableSeverity.val,
viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName,
swimlaneContainerWidth: explorerState.swimlaneContainerWidth,
viewByPerPage: explorerState.viewByPerPage,
viewByFromPage: explorerState.viewByFromPage,
}) || }) ||
undefined; undefined;
useEffect(() => { useEffect(() => {
loadExplorerData(loadExplorerDataConfig); if (explorerState && explorerState.swimlaneContainerWidth > 0) {
loadExplorerData({
...loadExplorerDataConfig,
swimlaneLimit:
explorerState?.viewBySwimlaneData &&
isViewBySwimLaneData(explorerState?.viewBySwimlaneData)
? explorerState?.viewBySwimlaneData.cardinality
: undefined,
});
}
}, [JSON.stringify(loadExplorerDataConfig)]); }, [JSON.stringify(loadExplorerDataConfig)]);
if (explorerState === undefined || refresh === undefined || showCharts === undefined) { if (explorerState === undefined || refresh === undefined || showCharts === undefined) {

View file

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

View file

@ -5,26 +5,40 @@
*/ */
import { useObservable } from 'react-use'; import { useObservable } from 'react-use';
import { merge, Observable } from 'rxjs'; import { merge } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, skip } from 'rxjs/operators';
import { useMemo } from 'react';
import { annotationsRefresh$ } from '../services/annotations_service'; import { annotationsRefresh$ } from '../services/annotations_service';
import { import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
mlTimefilterRefresh$, import { useTimefilter } from '../contexts/kibana';
mlTimefilterTimeChange$,
} from '../services/timefilter_refresh_service';
export interface Refresh { export interface Refresh {
lastRefresh: number; lastRefresh: number;
timeRange?: { start: string; end: string }; timeRange?: { start: string; end: string };
} }
const refresh$: Observable<Refresh> = merge( /**
mlTimefilterRefresh$, * Hook that provides the latest refresh timestamp
mlTimefilterTimeChange$, * and the most recent applied time range.
annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d }))) */
);
export const useRefresh = () => { 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$); return useObservable<Refresh>(refresh$);
}; };

View file

@ -12,14 +12,19 @@ import {
UI_SETTINGS, UI_SETTINGS,
} from '../../../../../../src/plugins/data/public'; } from '../../../../../../src/plugins/data/public';
import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets'; 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 { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants';
import { MlResultsService } from './results_service'; 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 timeBuckets: TimeBuckets;
private _customTimeRange: TimeRange | undefined; private _customTimeRange: TimeRange | undefined;
@ -130,12 +135,27 @@ export class ExplorerService {
return overallSwimlaneData; 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( public async loadViewBySwimlane(
fieldValues: string[], fieldValues: string[],
bounds: { earliest: number; latest: number }, bounds: { earliest: number; latest: number },
selectedJobs: ExplorerJob[], selectedJobs: ExplorerJob[],
viewBySwimlaneFieldName: string, viewBySwimlaneFieldName: string,
swimlaneLimit: number, swimlaneLimit: number,
perPage: number,
fromPage: number,
swimlaneContainerWidth: number, swimlaneContainerWidth: number,
influencersFilterQuery?: any influencersFilterQuery?: any
): Promise<SwimlaneData | undefined> { ): Promise<SwimlaneData | undefined> {
@ -172,7 +192,8 @@ export class ExplorerService {
searchBounds.min.valueOf(), searchBounds.min.valueOf(),
searchBounds.max.valueOf(), searchBounds.max.valueOf(),
interval, interval,
swimlaneLimit perPage,
fromPage
); );
} else { } else {
response = await this.mlResultsService.getInfluencerValueMaxScoreByTime( response = await this.mlResultsService.getInfluencerValueMaxScoreByTime(
@ -183,6 +204,8 @@ export class ExplorerService {
searchBounds.max.valueOf(), searchBounds.max.valueOf(),
interval, interval,
swimlaneLimit, swimlaneLimit,
perPage,
fromPage,
influencersFilterQuery influencersFilterQuery
); );
} }
@ -193,6 +216,7 @@ export class ExplorerService {
const viewBySwimlaneData = this.processViewByResults( const viewBySwimlaneData = this.processViewByResults(
response.results, response.results,
response.cardinality,
fieldValues, fieldValues,
bounds, bounds,
viewBySwimlaneFieldName, viewBySwimlaneFieldName,
@ -204,6 +228,55 @@ export class ExplorerService {
return viewBySwimlaneData; 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 { private getTimeBounds(): TimeRangeBounds {
return this._customTimeRange !== undefined return this._customTimeRange !== undefined
? this.timeFilter.calculateBounds(this._customTimeRange) ? this.timeFilter.calculateBounds(this._customTimeRange)
@ -245,6 +318,7 @@ export class ExplorerService {
private processViewByResults( private processViewByResults(
scoresByInfluencerAndTime: Record<string, { [timeMs: number]: number }>, scoresByInfluencerAndTime: Record<string, { [timeMs: number]: number }>,
cardinality: number,
sortedLaneValues: string[], sortedLaneValues: string[],
bounds: any, bounds: any,
viewBySwimlaneFieldName: string, viewBySwimlaneFieldName: string,
@ -254,7 +328,7 @@ export class ExplorerService {
// Sorts the lanes according to the supplied array of lane // Sorts the lanes according to the supplied array of lane
// values in the order in which they should be displayed, // 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. // or pass an empty array to sort lanes according to max score over all time.
const dataset: OverallSwimlaneData = { const dataset: ViewBySwimLaneData = {
fieldName: viewBySwimlaneFieldName, fieldName: viewBySwimlaneFieldName,
points: [], points: [],
laneLabels: [], laneLabels: [],
@ -262,6 +336,7 @@ export class ExplorerService {
// Set the earliest and latest to be the same as the overall swim lane. // Set the earliest and latest to be the same as the overall swim lane.
earliest: bounds.earliest, earliest: bounds.earliest,
latest: bounds.latest, latest: bounds.latest,
cardinality,
}; };
const maxScoreByLaneLabel: Record<string, number> = {}; const maxScoreByLaneLabel: Record<string, number> = {};

View file

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

View file

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

View file

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

View file

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

View file

@ -14,9 +14,19 @@ export function resultsServiceProvider(
earliestMs: number, earliestMs: number,
latestMs: number, latestMs: number,
interval: string | 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>; ): Promise<any>;
getTopInfluencers(): Promise<any>;
getTopInfluencerValues(): Promise<any>; getTopInfluencerValues(): Promise<any>;
getOverallBucketScores( getOverallBucketScores(
jobIds: any, jobIds: any,
@ -33,6 +43,8 @@ export function resultsServiceProvider(
latestMs: number, latestMs: number,
interval: string, interval: string,
maxResults: number, maxResults: number,
perPage: number,
fromPage: number,
influencersFilterQuery: any influencersFilterQuery: any
): Promise<any>; ): Promise<any>;
getRecordInfluencers(): Promise<any>; getRecordInfluencers(): Promise<any>;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/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 { BehaviorSubject, Observable } from 'rxjs';
import { I18nProvider } from '@kbn/i18n/react'; import { I18nProvider } from '@kbn/i18n/react';
import { import {
@ -17,6 +17,7 @@ import { CoreStart } from 'kibana/public';
import { MlStartDependencies } from '../../plugin'; import { MlStartDependencies } from '../../plugin';
import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { useSwimlaneInputResolver } from './swimlane_input_resolver';
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
import { SwimlaneContainer } from '../../application/explorer/swimlane_container';
jest.mock('./swimlane_input_resolver', () => ({ jest.mock('./swimlane_input_resolver', () => ({
useSwimlaneInputResolver: jest.fn(() => { useSwimlaneInputResolver: jest.fn(() => {
@ -24,12 +25,11 @@ jest.mock('./swimlane_input_resolver', () => ({
}), }),
})); }));
jest.mock('../../application/explorer/explorer_swimlane', () => ({ jest.mock('../../application/explorer/swimlane_container', () => ({
ExplorerSwimlane: jest.fn(), SwimlaneContainer: jest.fn(() => {
})); return null;
}),
jest.mock('../../application/components/chart_tooltip', () => ({ isViewBySwimLaneData: jest.fn(),
MlTooltipComponent: jest.fn(),
})); }));
const defaultOptions = { wrapper: I18nProvider }; const defaultOptions = { wrapper: I18nProvider };
@ -38,6 +38,7 @@ describe('ExplorerSwimlaneContainer', () => {
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>; let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: BehaviorSubject<any>; let refresh: BehaviorSubject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
const onInputChange = jest.fn();
beforeEach(() => { beforeEach(() => {
embeddableInput = new BehaviorSubject({ embeddableInput = new BehaviorSubject({
@ -61,25 +62,39 @@ describe('ExplorerSwimlaneContainer', () => {
}; };
(useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([ (useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([
mockOverallData,
SWIMLANE_TYPE.OVERALL, SWIMLANE_TYPE.OVERALL,
undefined, mockOverallData,
10,
jest.fn(),
{},
false,
null,
]); ]);
const { findByTestId } = render( render(
<ExplorerSwimlaneContainer <EmbeddableSwimLaneContainer
id={'test-swimlane-embeddable'} id={'test-swimlane-embeddable'}
embeddableInput={ embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput> embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
} }
services={services} services={services}
refresh={refresh} refresh={refresh}
onInputChange={onInputChange}
/>, />,
defaultOptions defaultOptions
); );
expect(
await findByTestId('mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable') const calledWith = ((SwimlaneContainer as unknown) as jest.Mock<typeof SwimlaneContainer>).mock
).toBeDefined(); .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 () => { 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, undefined,
undefined,
undefined,
false,
{ message: 'Something went wrong' }, { message: 'Something went wrong' },
]); ]);
const { findByText } = render( const { findByText } = render(
<ExplorerSwimlaneContainer <EmbeddableSwimLaneContainer
id={'test-swimlane-embeddable'} id={'test-swimlane-embeddable'}
embeddableInput={ embeddableInput={
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput> embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
} }
services={services} services={services}
refresh={refresh} refresh={refresh}
onInputChange={onInputChange}
/>, />,
defaultOptions defaultOptions
); );
const errorMessage = await findByText('Something went wrong'); const errorMessage = await findByText('Something went wrong');
expect(errorMessage).toBeDefined(); 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 embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
let refresh: Subject<any>; let refresh: Subject<any>;
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
let onInputChange: jest.Mock;
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
@ -41,7 +42,7 @@ describe('useSwimlaneInputResolver', () => {
} as CoreStart, } as CoreStart,
(null as unknown) as MlStartDependencies, (null as unknown) as MlStartDependencies,
({ ({
explorerService: { anomalyTimelineService: {
setTimeRange: jest.fn(), setTimeRange: jest.fn(),
loadOverallData: jest.fn(() => loadOverallData: jest.fn(() =>
Promise.resolve({ Promise.resolve({
@ -69,6 +70,7 @@ describe('useSwimlaneInputResolver', () => {
}, },
} as unknown) as AnomalySwimlaneServices, } as unknown) as AnomalySwimlaneServices,
]; ];
onInputChange = jest.fn();
}); });
afterEach(() => { afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
@ -79,9 +81,11 @@ describe('useSwimlaneInputResolver', () => {
const { result, waitForNextUpdate } = renderHook(() => const { result, waitForNextUpdate } = renderHook(() =>
useSwimlaneInputResolver( useSwimlaneInputResolver(
embeddableInput as Observable<AnomalySwimlaneEmbeddableInput>, embeddableInput as Observable<AnomalySwimlaneEmbeddableInput>,
onInputChange,
refresh, refresh,
services, services,
1000 1000,
1
) )
); );
@ -94,7 +98,7 @@ describe('useSwimlaneInputResolver', () => {
}); });
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1); expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(1); expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(1);
await act(async () => { await act(async () => {
embeddableInput.next({ embeddableInput.next({
@ -109,7 +113,7 @@ describe('useSwimlaneInputResolver', () => {
}); });
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(2); expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(2);
await act(async () => { await act(async () => {
embeddableInput.next({ embeddableInput.next({
@ -124,7 +128,7 @@ describe('useSwimlaneInputResolver', () => {
}); });
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); 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, skipWhile,
startWith, startWith,
switchMap, switchMap,
tap,
} from 'rxjs/operators'; } from 'rxjs/operators';
import { CoreStart } from 'kibana/public'; import { CoreStart } from 'kibana/public';
import { TimeBuckets } from '../../application/util/time_buckets'; import { TimeBuckets } from '../../application/util/time_buckets';
import { import {
AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableInput,
AnomalySwimlaneEmbeddableOutput,
AnomalySwimlaneServices, AnomalySwimlaneServices,
} from './anomaly_swimlane_embeddable'; } from './anomaly_swimlane_embeddable';
import { MlStartDependencies } from '../../plugin'; 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 { Filter } from '../../../../../../src/plugins/data/common/es_query/filters';
import { Query } from '../../../../../../src/plugins/data/common/query'; import { Query } from '../../../../../../src/plugins/data/common/query';
import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils';
import { parseInterval } from '../../../common/util/parse_interval'; import { parseInterval } from '../../../common/util/parse_interval';
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; 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; const FETCH_RESULTS_DEBOUNCE_MS = 500;
function getJobsObservable( function getJobsObservable(
@ -48,17 +56,31 @@ function getJobsObservable(
export function useSwimlaneInputResolver( export function useSwimlaneInputResolver(
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>, embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>,
onInputChange: (output: Partial<AnomalySwimlaneEmbeddableOutput>) => void,
refresh: Observable<any>, refresh: Observable<any>,
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices],
chartWidth: number chartWidth: number,
): [string | undefined, OverallSwimlaneData | undefined, TimeBuckets, Error | null | undefined] { fromPage: number
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services; ): [
string | undefined,
OverallSwimlaneData | undefined,
number,
(perPage: number) => void,
TimeBuckets,
boolean,
Error | null | undefined
] {
const [{ uiSettings }, , { anomalyTimelineService, anomalyDetectorService }] = services;
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>(); const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>(); const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>();
const [error, setError] = useState<Error | null>(); const [error, setError] = useState<Error | null>();
const [perPage, setPerPage] = useState<number | undefined>();
const [isLoading, setIsLoading] = useState(false);
const chartWidth$ = useMemo(() => new Subject<number>(), []); const chartWidth$ = useMemo(() => new Subject<number>(), []);
const fromPage$ = useMemo(() => new Subject<number>(), []);
const perPage$ = useMemo(() => new Subject<number>(), []);
const timeBuckets = useMemo(() => { const timeBuckets = useMemo(() => {
return new TimeBuckets({ return new TimeBuckets({
@ -73,28 +95,32 @@ export function useSwimlaneInputResolver(
const subscription = combineLatest([ const subscription = combineLatest([
getJobsObservable(embeddableInput, anomalyDetectorService), getJobsObservable(embeddableInput, anomalyDetectorService),
embeddableInput, embeddableInput,
chartWidth$.pipe( chartWidth$.pipe(skipWhile((v) => !v)),
skipWhile((v) => !v), fromPage$,
distinctUntilChanged((prev, curr) => { perPage$.pipe(
// emit only if the width has been changed significantly startWith(undefined),
return Math.abs(curr - prev) < RESIZE_IGNORED_DIFF_PX; // 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)), refresh.pipe(startWith(null)),
]) ])
.pipe( .pipe(
tap(setIsLoading.bind(null, true)),
debounceTime(FETCH_RESULTS_DEBOUNCE_MS), debounceTime(FETCH_RESULTS_DEBOUNCE_MS),
switchMap(([jobs, input, swimlaneContainerWidth]) => { switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => {
const { const {
viewBy, viewBy,
swimlaneType: swimlaneTypeInput, swimlaneType: swimlaneTypeInput,
limit, perPage: perPageInput,
timeRange, timeRange,
filters, filters,
query, query,
viewMode,
} = input; } = input;
explorerService.setTimeRange(timeRange); anomalyTimelineService.setTimeRange(timeRange);
if (!swimlaneType) { if (!swimlaneType) {
setSwimlaneType(swimlaneTypeInput); setSwimlaneType(swimlaneTypeInput);
@ -118,18 +144,34 @@ export function useSwimlaneInputResolver(
return of(undefined); return of(undefined);
} }
return from(explorerService.loadOverallData(explorerJobs, swimlaneContainerWidth)).pipe( return from(
anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth)
).pipe(
switchMap((overallSwimlaneData) => { switchMap((overallSwimlaneData) => {
const { earliest, latest } = overallSwimlaneData; const { earliest, latest } = overallSwimlaneData;
if (overallSwimlaneData && swimlaneTypeInput === SWIMLANE_TYPE.VIEW_BY) { 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( return from(
explorerService.loadViewBySwimlane( anomalyTimelineService.loadViewBySwimlane(
[], [],
{ earliest, latest }, { earliest, latest },
explorerJobs, explorerJobs,
viewBy!, viewBy!,
limit!, isViewBySwimLaneData(swimlaneData)
? swimlaneData.cardinality
: ANOMALY_SWIM_LANE_HARD_LIMIT,
perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE,
fromPageInput,
swimlaneContainerWidth, swimlaneContainerWidth,
appliedFilters appliedFilters
) )
@ -156,6 +198,7 @@ export function useSwimlaneInputResolver(
if (data !== undefined) { if (data !== undefined) {
setError(null); setError(null);
setSwimlaneData(data); 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(() => { useEffect(() => {
chartWidth$.next(chartWidth); chartWidth$.next(chartWidth);
}, [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) { export function processFilters(filters: Filter[], query: Query) {

View file

@ -14,8 +14,6 @@ import {
AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneEmbeddableOutput,
} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout'; import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout';
import { HttpService } from '../application/services/http_service';
import { AnomalyDetectorService } from '../application/services/anomaly_detector_service';
export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction'; export 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'); throw new Error('Not possible to execute an action without the embeddable context');
} }
const [{ overlays, uiSettings, http }] = await getStartServices(); const [coreStart] = await getStartServices();
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
try { try {
const result = await resolveAnomalySwimlaneUserInput( const result = await resolveAnomalySwimlaneUserInput(coreStart, embeddable.getInput());
{
anomalyDetectorService,
overlays,
uiSettings,
},
embeddable.getInput()
);
embeddable.updateInput(result); embeddable.updateInput(result);
} catch (e) { } catch (e) {
return Promise.reject(); return Promise.reject();

View file

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

View file

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

View file

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