[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:
parent
335c9bb148
commit
854e7a5204
|
@ -7,7 +7,7 @@
|
|||
import React, { FC } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { AppMountParameters, CoreStart } from 'kibana/public';
|
||||
import { AppMountParameters, CoreStart, HttpStart } from 'kibana/public';
|
||||
|
||||
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
|
@ -17,6 +17,8 @@ import { setLicenseCache } from './license';
|
|||
import { MlSetupDependencies, MlStartDependencies } from '../plugin';
|
||||
|
||||
import { MlRouter } from './routing';
|
||||
import { mlApiServicesProvider } from './services/ml_api_service';
|
||||
import { HttpService } from './services/http_service';
|
||||
|
||||
type MlDependencies = MlSetupDependencies & MlStartDependencies;
|
||||
|
||||
|
@ -27,6 +29,23 @@ interface AppProps {
|
|||
|
||||
const localStorage = new Storage(window.localStorage);
|
||||
|
||||
/**
|
||||
* Provides global services available across the entire ML app.
|
||||
*/
|
||||
export function getMlGlobalServices(httpStart: HttpStart) {
|
||||
const httpService = new HttpService(httpStart);
|
||||
return {
|
||||
httpService,
|
||||
mlApiServices: mlApiServicesProvider(httpService),
|
||||
};
|
||||
}
|
||||
|
||||
export interface MlServicesContext {
|
||||
mlServices: MlGlobalServices;
|
||||
}
|
||||
|
||||
export type MlGlobalServices = ReturnType<typeof getMlGlobalServices>;
|
||||
|
||||
const App: FC<AppProps> = ({ coreStart, deps }) => {
|
||||
const pageDeps = {
|
||||
indexPatterns: deps.data.indexPatterns,
|
||||
|
@ -47,7 +66,9 @@ const App: FC<AppProps> = ({ coreStart, deps }) => {
|
|||
const I18nContext = coreStart.i18n.Context;
|
||||
return (
|
||||
<I18nContext>
|
||||
<KibanaContextProvider services={services}>
|
||||
<KibanaContextProvider
|
||||
services={{ ...services, mlServices: getMlGlobalServices(coreStart.http) }}
|
||||
>
|
||||
<MlRouter pageDeps={pageDeps} />
|
||||
</KibanaContextProvider>
|
||||
</I18nContext>
|
||||
|
|
|
@ -27,7 +27,6 @@ import {
|
|||
normalizeTimes,
|
||||
} from './job_select_service_utils';
|
||||
import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs';
|
||||
import { ml } from '../../services/ml_api_service';
|
||||
import { useMlKibana } from '../../contexts/kibana';
|
||||
import { JobSelectionMaps } from './job_selector';
|
||||
|
||||
|
@ -66,7 +65,10 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
|
|||
withTimeRangeSelector = true,
|
||||
}) => {
|
||||
const {
|
||||
services: { notifications },
|
||||
services: {
|
||||
notifications,
|
||||
mlServices: { mlApiServices },
|
||||
},
|
||||
} = useMlKibana();
|
||||
|
||||
const [newSelection, setNewSelection] = useState(selectedIds);
|
||||
|
@ -151,7 +153,7 @@ export const JobSelectorFlyout: FC<JobSelectorFlyoutProps> = ({
|
|||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
const resp = await ml.jobs.jobsWithTimerange(dateFormatTz);
|
||||
const resp = await mlApiServices.jobs.jobsWithTimerange(dateFormatTz);
|
||||
const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
|
||||
const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
|
||||
setJobs(normalizedJobs);
|
||||
|
|
|
@ -9,10 +9,7 @@ import { Subscription } from 'rxjs';
|
|||
import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui';
|
||||
import { TimeRange, TimeHistoryContract } from 'src/plugins/data/public';
|
||||
|
||||
import {
|
||||
mlTimefilterRefresh$,
|
||||
mlTimefilterTimeChange$,
|
||||
} from '../../../services/timefilter_refresh_service';
|
||||
import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service';
|
||||
import { useUrlState } from '../../../util/url_state';
|
||||
import { useMlKibana } from '../../../contexts/kibana';
|
||||
|
||||
|
@ -108,7 +105,6 @@ export const DatePickerWrapper: FC = () => {
|
|||
timefilter.setTime(newTime);
|
||||
setTime(newTime);
|
||||
setRecentlyUsedRanges(getRecentlyUsedRanges());
|
||||
mlTimefilterTimeChange$.next({ lastRefresh: Date.now(), timeRange: { start, end } });
|
||||
}
|
||||
|
||||
function updateInterval({
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { SecurityPluginSetup } from '../../../../../security/public';
|
||||
import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public';
|
||||
import { SharePluginStart } from '../../../../../../../src/plugins/share/public';
|
||||
import { MlServicesContext } from '../../app';
|
||||
|
||||
interface StartPlugins {
|
||||
data: DataPublicPluginStart;
|
||||
|
@ -20,7 +21,8 @@ interface StartPlugins {
|
|||
licenseManagement?: LicenseManagementUIPluginSetup;
|
||||
share: SharePluginStart;
|
||||
}
|
||||
export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string };
|
||||
export type StartServices = CoreStart &
|
||||
StartPlugins & { kibanaVersion: string } & MlServicesContext;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
export const useMlKibana = () => useKibana<StartServices>();
|
||||
export type MlKibanaReactContextValue = KibanaReactContextValue<StartServices>;
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import React from 'react';
|
||||
import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public';
|
||||
import { SavedSearchSavedObject } from '../../../../common/types/kibana';
|
||||
import { MlServicesContext } from '../../app';
|
||||
|
||||
export interface MlContextValue {
|
||||
combinedQuery: any;
|
||||
|
@ -34,4 +35,4 @@ export type SavedSearchQuery = object;
|
|||
// Multiple custom hooks can be created to access subsets of
|
||||
// the overall context value if necessary too,
|
||||
// see useCurrentIndexPattern() for example.
|
||||
export const MlContext = React.createContext<Partial<MlContextValue>>({});
|
||||
export const MlContext = React.createContext<Partial<MlContextValue & MlServicesContext>>({});
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
$borderRadius: $euiBorderRadius / 2;
|
||||
|
||||
.ml-swimlane-selector {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
@ -104,10 +106,9 @@
|
|||
|
||||
// SASSTODO: This entire selector needs to be rewritten.
|
||||
// It looks extremely brittle with very specific sizing units
|
||||
.ml-explorer-swimlane {
|
||||
.mlExplorerSwimlane {
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
margin-bottom: $euiSizeS;
|
||||
|
||||
line.gridLine {
|
||||
stroke: $euiBorderColor;
|
||||
|
@ -218,17 +219,20 @@
|
|||
div.lane {
|
||||
height: 30px;
|
||||
border-bottom: 0px;
|
||||
border-radius: 2px;
|
||||
margin-top: -1px;
|
||||
border-radius: $borderRadius;
|
||||
white-space: nowrap;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
div.lane-label {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-size: $euiFontSizeXS;
|
||||
height: 30px;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
border-radius: $borderRadius;
|
||||
padding-right: 5px;
|
||||
margin-right: 5px;
|
||||
border: 1px solid transparent;
|
||||
|
@ -261,7 +265,7 @@
|
|||
.sl-cell-inner-dragselect {
|
||||
height: 26px;
|
||||
margin: 1px;
|
||||
border-radius: 2px;
|
||||
border-radius: $borderRadius;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
@ -293,7 +297,7 @@
|
|||
.sl-cell-inner,
|
||||
.sl-cell-inner-dragselect {
|
||||
border: 2px solid $euiColorDarkShade;
|
||||
border-radius: 2px;
|
||||
border-radius: $borderRadius;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import useObservable from 'react-use/lib/useObservable';
|
|||
import { forkJoin, of, Observable, Subject } from 'rxjs';
|
||||
import { mergeMap, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service';
|
||||
import { explorerService } from '../explorer_dashboard_service';
|
||||
import {
|
||||
|
@ -22,15 +23,17 @@ import {
|
|||
loadAnomaliesTableData,
|
||||
loadDataForCharts,
|
||||
loadFilteredTopInfluencers,
|
||||
loadOverallData,
|
||||
loadTopInfluencers,
|
||||
loadViewBySwimlane,
|
||||
loadViewByTopFieldValuesForSelectedTime,
|
||||
AppStateSelectedCells,
|
||||
ExplorerJob,
|
||||
TimeRangeBounds,
|
||||
} from '../explorer_utils';
|
||||
import { ExplorerState } from '../reducers';
|
||||
import { useMlKibana, useTimefilter } from '../../contexts/kibana';
|
||||
import { AnomalyTimelineService } from '../../services/anomaly_timeline_service';
|
||||
import { mlResultsServiceProvider } from '../../services/results_service';
|
||||
import { isViewBySwimLaneData } from '../swimlane_container';
|
||||
import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants';
|
||||
|
||||
// Memoize the data fetching methods.
|
||||
// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument
|
||||
|
@ -39,13 +42,13 @@ import { ExplorerState } from '../reducers';
|
|||
// about this parameter. The generic type T retains and returns the type information of
|
||||
// the original function.
|
||||
const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs);
|
||||
const wrapWithLastRefreshArg = <T extends (...a: any[]) => any>(func: T) => {
|
||||
const wrapWithLastRefreshArg = <T extends (...a: any[]) => any>(func: T, context: any = null) => {
|
||||
return function (lastRefresh: number, ...args: Parameters<T>): ReturnType<T> {
|
||||
return func.apply(null, args);
|
||||
return func.apply(context, args);
|
||||
};
|
||||
};
|
||||
const memoize = <T extends (...a: any[]) => any>(func: T) => {
|
||||
return memoizeOne(wrapWithLastRefreshArg<T>(func), memoizeIsEqual);
|
||||
const memoize = <T extends (...a: any[]) => any>(func: T, context?: any) => {
|
||||
return memoizeOne(wrapWithLastRefreshArg<T>(func, context), memoizeIsEqual);
|
||||
};
|
||||
|
||||
const memoizedAnomalyDataChange = memoize<typeof anomalyDataChange>(anomalyDataChange);
|
||||
|
@ -56,9 +59,7 @@ const memoizedLoadDataForCharts = memoize<typeof loadDataForCharts>(loadDataForC
|
|||
const memoizedLoadFilteredTopInfluencers = memoize<typeof loadFilteredTopInfluencers>(
|
||||
loadFilteredTopInfluencers
|
||||
);
|
||||
const memoizedLoadOverallData = memoize(loadOverallData);
|
||||
const memoizedLoadTopInfluencers = memoize(loadTopInfluencers);
|
||||
const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane);
|
||||
const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData);
|
||||
|
||||
export interface LoadExplorerDataConfig {
|
||||
|
@ -73,6 +74,9 @@ export interface LoadExplorerDataConfig {
|
|||
tableInterval: string;
|
||||
tableSeverity: number;
|
||||
viewBySwimlaneFieldName: string;
|
||||
viewByFromPage: number;
|
||||
viewByPerPage: number;
|
||||
swimlaneContainerWidth: number;
|
||||
}
|
||||
|
||||
export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => {
|
||||
|
@ -87,183 +91,213 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi
|
|||
|
||||
/**
|
||||
* Fetches the data necessary for the Anomaly Explorer using observables.
|
||||
*
|
||||
* @param config LoadExplorerDataConfig
|
||||
*
|
||||
* @return Partial<ExplorerState>
|
||||
*/
|
||||
function loadExplorerData(config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> {
|
||||
if (!isLoadExplorerDataConfig(config)) {
|
||||
return of({});
|
||||
}
|
||||
|
||||
const {
|
||||
bounds,
|
||||
lastRefresh,
|
||||
influencersFilterQuery,
|
||||
noInfluencersConfigured,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
swimlaneBucketInterval,
|
||||
swimlaneLimit,
|
||||
tableInterval,
|
||||
tableSeverity,
|
||||
viewBySwimlaneFieldName,
|
||||
} = config;
|
||||
|
||||
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName);
|
||||
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
|
||||
const timerange = getSelectionTimeRange(
|
||||
selectedCells,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds
|
||||
const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService) => {
|
||||
const memoizedLoadOverallData = memoize(
|
||||
anomalyTimelineService.loadOverallData,
|
||||
anomalyTimelineService
|
||||
);
|
||||
const memoizedLoadViewBySwimlane = memoize(
|
||||
anomalyTimelineService.loadViewBySwimlane,
|
||||
anomalyTimelineService
|
||||
);
|
||||
return (config: LoadExplorerDataConfig): Observable<Partial<ExplorerState>> => {
|
||||
if (!isLoadExplorerDataConfig(config)) {
|
||||
return of({});
|
||||
}
|
||||
|
||||
const dateFormatTz = getDateFormatTz();
|
||||
|
||||
// First get the data where we have all necessary args at hand using forkJoin:
|
||||
// annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues
|
||||
return forkJoin({
|
||||
annotationsData: memoizedLoadAnnotationsTableData(
|
||||
const {
|
||||
bounds,
|
||||
lastRefresh,
|
||||
influencersFilterQuery,
|
||||
noInfluencersConfigured,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds
|
||||
),
|
||||
anomalyChartRecords: memoizedLoadDataForCharts(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
selectionInfluencers,
|
||||
selectedCells,
|
||||
influencersFilterQuery
|
||||
),
|
||||
influencers:
|
||||
selectionInfluencers.length === 0
|
||||
? memoizedLoadTopInfluencers(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
[],
|
||||
noInfluencersConfigured,
|
||||
influencersFilterQuery
|
||||
)
|
||||
: Promise.resolve({}),
|
||||
overallState: memoizedLoadOverallData(
|
||||
lastRefresh,
|
||||
selectedJobs,
|
||||
swimlaneBucketInterval,
|
||||
bounds
|
||||
),
|
||||
tableData: memoizedLoadAnomaliesTableData(
|
||||
lastRefresh,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
dateFormatTz,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
tableInterval,
|
||||
tableSeverity,
|
||||
influencersFilterQuery
|
||||
),
|
||||
topFieldValues:
|
||||
selectedCells !== undefined && selectedCells.showTopFieldValues === true
|
||||
? loadViewByTopFieldValuesForSelectedTime(
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneContainerWidth,
|
||||
viewByFromPage,
|
||||
viewByPerPage,
|
||||
} = config;
|
||||
|
||||
const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName);
|
||||
const jobIds = getSelectionJobIds(selectedCells, selectedJobs);
|
||||
const timerange = getSelectionTimeRange(
|
||||
selectedCells,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds
|
||||
);
|
||||
|
||||
const dateFormatTz = getDateFormatTz();
|
||||
|
||||
// First get the data where we have all necessary args at hand using forkJoin:
|
||||
// annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues
|
||||
return forkJoin({
|
||||
annotationsData: memoizedLoadAnnotationsTableData(
|
||||
lastRefresh,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds
|
||||
),
|
||||
anomalyChartRecords: memoizedLoadDataForCharts(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
selectionInfluencers,
|
||||
selectedCells,
|
||||
influencersFilterQuery
|
||||
),
|
||||
influencers:
|
||||
selectionInfluencers.length === 0
|
||||
? memoizedLoadTopInfluencers(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
[],
|
||||
noInfluencersConfigured,
|
||||
influencersFilterQuery
|
||||
)
|
||||
: Promise.resolve({}),
|
||||
overallState: memoizedLoadOverallData(lastRefresh, selectedJobs, swimlaneContainerWidth),
|
||||
tableData: memoizedLoadAnomaliesTableData(
|
||||
lastRefresh,
|
||||
selectedCells,
|
||||
selectedJobs,
|
||||
dateFormatTz,
|
||||
swimlaneBucketInterval.asSeconds(),
|
||||
bounds,
|
||||
viewBySwimlaneFieldName,
|
||||
tableInterval,
|
||||
tableSeverity,
|
||||
influencersFilterQuery
|
||||
),
|
||||
topFieldValues:
|
||||
selectedCells !== undefined && selectedCells.showTopFieldValues === true
|
||||
? anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime(
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
viewByPerPage,
|
||||
viewByFromPage,
|
||||
swimlaneContainerWidth
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
}).pipe(
|
||||
// Trigger a side-effect action to reset view-by swimlane,
|
||||
// show the view-by loading indicator
|
||||
// and pass on the data we already fetched.
|
||||
tap(explorerService.setViewBySwimlaneLoading),
|
||||
// Trigger a side-effect to update the charts.
|
||||
tap(({ anomalyChartRecords }) => {
|
||||
if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
anomalyChartRecords,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
noInfluencersConfigured
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
}).pipe(
|
||||
// Trigger a side-effect action to reset view-by swimlane,
|
||||
// show the view-by loading indicator
|
||||
// and pass on the data we already fetched.
|
||||
tap(explorerService.setViewBySwimlaneLoading),
|
||||
// Trigger a side-effect to update the charts.
|
||||
tap(({ anomalyChartRecords }) => {
|
||||
if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
anomalyChartRecords,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
tableSeverity
|
||||
);
|
||||
} else {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
[],
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
tableSeverity
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Load view-by swimlane data and filtered top influencers.
|
||||
// mergeMap is used to have access to the already fetched data and act on it in arg #1.
|
||||
// In arg #2 of mergeMap we combine the data and pass it on in the action format
|
||||
// which can be consumed by explorerReducer() later on.
|
||||
mergeMap(
|
||||
({ anomalyChartRecords, influencers, overallState, topFieldValues }) =>
|
||||
forkJoin({
|
||||
influencers:
|
||||
(selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
|
||||
anomalyChartRecords !== undefined &&
|
||||
anomalyChartRecords.length > 0
|
||||
? memoizedLoadFilteredTopInfluencers(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
anomalyChartRecords,
|
||||
selectionInfluencers,
|
||||
noInfluencersConfigured,
|
||||
influencersFilterQuery
|
||||
)
|
||||
: Promise.resolve(influencers),
|
||||
viewBySwimlaneState: memoizedLoadViewBySwimlane(
|
||||
tableSeverity
|
||||
);
|
||||
} else {
|
||||
memoizedAnomalyDataChange(
|
||||
lastRefresh,
|
||||
topFieldValues,
|
||||
{
|
||||
earliest: overallState.overallSwimlaneData.earliest,
|
||||
latest: overallState.overallSwimlaneData.latest,
|
||||
},
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
influencersFilterQuery,
|
||||
noInfluencersConfigured
|
||||
),
|
||||
}),
|
||||
(
|
||||
{ annotationsData, overallState, tableData },
|
||||
{ influencers, viewBySwimlaneState }
|
||||
): Partial<ExplorerState> => {
|
||||
return {
|
||||
annotationsData,
|
||||
influencers,
|
||||
...overallState,
|
||||
...viewBySwimlaneState,
|
||||
tableData,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const loadExplorerData$ = new Subject<LoadExplorerDataConfig>();
|
||||
const explorerData$ = loadExplorerData$.pipe(
|
||||
switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config))
|
||||
);
|
||||
|
||||
export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any) => void] => {
|
||||
const explorerData = useObservable(explorerData$);
|
||||
return [explorerData, (c) => loadExplorerData$.next(c)];
|
||||
[],
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
tableSeverity
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Load view-by swimlane data and filtered top influencers.
|
||||
// mergeMap is used to have access to the already fetched data and act on it in arg #1.
|
||||
// In arg #2 of mergeMap we combine the data and pass it on in the action format
|
||||
// which can be consumed by explorerReducer() later on.
|
||||
mergeMap(
|
||||
({ anomalyChartRecords, influencers, overallState, topFieldValues }) =>
|
||||
forkJoin({
|
||||
influencers:
|
||||
(selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) &&
|
||||
anomalyChartRecords !== undefined &&
|
||||
anomalyChartRecords.length > 0
|
||||
? memoizedLoadFilteredTopInfluencers(
|
||||
lastRefresh,
|
||||
jobIds,
|
||||
timerange.earliestMs,
|
||||
timerange.latestMs,
|
||||
anomalyChartRecords,
|
||||
selectionInfluencers,
|
||||
noInfluencersConfigured,
|
||||
influencersFilterQuery
|
||||
)
|
||||
: Promise.resolve(influencers),
|
||||
viewBySwimlaneState: memoizedLoadViewBySwimlane(
|
||||
lastRefresh,
|
||||
topFieldValues,
|
||||
{
|
||||
earliest: overallState.earliest,
|
||||
latest: overallState.latest,
|
||||
},
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
viewByPerPage,
|
||||
viewByFromPage,
|
||||
swimlaneContainerWidth,
|
||||
influencersFilterQuery
|
||||
),
|
||||
}),
|
||||
(
|
||||
{ annotationsData, overallState, tableData },
|
||||
{ influencers, viewBySwimlaneState }
|
||||
): Partial<ExplorerState> => {
|
||||
return {
|
||||
annotationsData,
|
||||
influencers,
|
||||
loading: false,
|
||||
viewBySwimlaneDataLoading: false,
|
||||
overallSwimlaneData: overallState,
|
||||
viewBySwimlaneData: viewBySwimlaneState,
|
||||
tableData,
|
||||
swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState)
|
||||
? viewBySwimlaneState.cardinality
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
export const useExplorerData = (): [Partial<ExplorerState> | undefined, (d: any) => void] => {
|
||||
const timefilter = useTimefilter();
|
||||
|
||||
const {
|
||||
services: {
|
||||
mlServices: { mlApiServices },
|
||||
uiSettings,
|
||||
},
|
||||
} = useMlKibana();
|
||||
const loadExplorerData = useMemo(() => {
|
||||
const service = new AnomalyTimelineService(
|
||||
timefilter,
|
||||
uiSettings,
|
||||
mlResultsServiceProvider(mlApiServices)
|
||||
);
|
||||
return loadExplorerDataProvider(service);
|
||||
}, []);
|
||||
const loadExplorerData$ = useMemo(() => new Subject<LoadExplorerDataConfig>(), []);
|
||||
const explorerData$ = useMemo(() => loadExplorerData$.pipe(switchMap(loadExplorerData)), []);
|
||||
const explorerData = useObservable(explorerData$);
|
||||
|
||||
const update = useCallback((c) => {
|
||||
loadExplorerData$.next(c);
|
||||
}, []);
|
||||
|
||||
return [explorerData, update];
|
||||
};
|
||||
|
|
|
@ -52,7 +52,6 @@ function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) {
|
|||
interface AddToDashboardControlProps {
|
||||
jobIds: JobId[];
|
||||
viewBy: string;
|
||||
limit: number;
|
||||
onClose: (callback?: () => Promise<any>) => void;
|
||||
}
|
||||
|
||||
|
@ -63,7 +62,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
|
|||
onClose,
|
||||
jobIds,
|
||||
viewBy,
|
||||
limit,
|
||||
}) => {
|
||||
const {
|
||||
notifications: { toasts },
|
||||
|
@ -141,7 +139,6 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
|
|||
jobIds,
|
||||
swimlaneType,
|
||||
viewBy,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -206,8 +203,8 @@ export const AddToDashboardControl: FC<AddToDashboardControlProps> = ({
|
|||
{
|
||||
id: SWIMLANE_TYPE.VIEW_BY,
|
||||
label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', {
|
||||
defaultMessage: 'View by {viewByField}, up to {limit} rows',
|
||||
values: { viewByField: viewBy, limit },
|
||||
defaultMessage: 'View by {viewByField}',
|
||||
values: { viewByField: viewBy },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -22,12 +22,11 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants';
|
||||
import { DRAG_SELECT_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants';
|
||||
import { AddToDashboardControl } from './add_to_dashboard_control';
|
||||
import { useMlKibana } from '../contexts/kibana';
|
||||
import { TimeBuckets } from '../util/time_buckets';
|
||||
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
|
||||
import { SelectLimit } from './select_limit';
|
||||
import {
|
||||
ALLOW_CELL_RANGE_SELECTION,
|
||||
dragSelect$,
|
||||
|
@ -36,9 +35,9 @@ import {
|
|||
import { ExplorerState } from './reducers/explorer_reducer';
|
||||
import { hasMatchingPoints } from './has_matching_points';
|
||||
import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found';
|
||||
import { LoadingIndicator } from '../components/loading_indicator';
|
||||
import { SwimlaneContainer } from './swimlane_container';
|
||||
import { OverallSwimlaneData } from './explorer_utils';
|
||||
import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
|
||||
import { NoOverallData } from './components/no_overall_data';
|
||||
|
||||
function mapSwimlaneOptionsToEuiOptions(options: string[]) {
|
||||
return options.map((option) => ({
|
||||
|
@ -132,8 +131,11 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
viewBySwimlaneDataLoading,
|
||||
viewBySwimlaneFieldName,
|
||||
viewBySwimlaneOptions,
|
||||
swimlaneLimit,
|
||||
selectedJobs,
|
||||
viewByFromPage,
|
||||
viewByPerPage,
|
||||
swimlaneLimit,
|
||||
loading,
|
||||
} = explorerState;
|
||||
|
||||
const setSwimlaneSelectActive = useCallback((active: boolean) => {
|
||||
|
@ -159,25 +161,18 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
}, []);
|
||||
|
||||
// Listener for click events in the swimlane to load corresponding anomaly data.
|
||||
const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => {
|
||||
// If selectedCells is an empty object we clear any existing selection,
|
||||
// otherwise we save the new selection in AppState and update the Explorer.
|
||||
if (Object.keys(selectedCellsUpdate).length === 0) {
|
||||
setSelectedCells();
|
||||
} else {
|
||||
setSelectedCells(selectedCellsUpdate);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showOverallSwimlane =
|
||||
overallSwimlaneData !== null &&
|
||||
overallSwimlaneData.laneLabels &&
|
||||
overallSwimlaneData.laneLabels.length > 0;
|
||||
|
||||
const showViewBySwimlane =
|
||||
viewBySwimlaneData !== null &&
|
||||
viewBySwimlaneData.laneLabels &&
|
||||
viewBySwimlaneData.laneLabels.length > 0;
|
||||
const swimlaneCellClick = useCallback(
|
||||
(selectedCellsUpdate: any) => {
|
||||
// If selectedCells is an empty object we clear any existing selection,
|
||||
// otherwise we save the new selection in AppState and update the Explorer.
|
||||
if (Object.keys(selectedCellsUpdate).length === 0) {
|
||||
setSelectedCells();
|
||||
} else {
|
||||
setSelectedCells(selectedCellsUpdate);
|
||||
}
|
||||
},
|
||||
[setSelectedCells]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items = [];
|
||||
|
@ -235,21 +230,6 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
/>
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<span className="eui-textNoWrap">
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.limitLabel"
|
||||
defaultMessage="Limit"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
display={'columnCompressed'}
|
||||
>
|
||||
<SelectLimit />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
|
||||
<div className="panel-sub-title">
|
||||
{viewByLoadedForTimeFormatted && (
|
||||
|
@ -305,68 +285,84 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
<EuiSpacer size="m" />
|
||||
|
||||
<div
|
||||
className="ml-explorer-swimlane euiText"
|
||||
className="mlExplorerSwimlane euiText"
|
||||
onMouseEnter={onSwimlaneEnterHandler}
|
||||
onMouseLeave={onSwimlaneLeaveHandler}
|
||||
data-test-subj="mlAnomalyExplorerSwimlaneOverall"
|
||||
>
|
||||
{showOverallSwimlane && (
|
||||
<SwimlaneContainer
|
||||
filterActive={filterActive}
|
||||
maskAll={maskAll}
|
||||
timeBuckets={timeBuckets}
|
||||
swimlaneCellClick={swimlaneCellClick}
|
||||
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
|
||||
swimlaneType={'overall'}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
|
||||
/>
|
||||
)}
|
||||
<SwimlaneContainer
|
||||
filterActive={filterActive}
|
||||
maskAll={maskAll}
|
||||
timeBuckets={timeBuckets}
|
||||
swimlaneCellClick={swimlaneCellClick}
|
||||
swimlaneData={overallSwimlaneData as OverallSwimlaneData}
|
||||
swimlaneType={'overall'}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
|
||||
isLoading={loading}
|
||||
noDataWarning={<NoOverallData />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
{viewBySwimlaneOptions.length > 0 && (
|
||||
<>
|
||||
{showViewBySwimlane && (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<div
|
||||
className="ml-explorer-swimlane euiText"
|
||||
onMouseEnter={onSwimlaneEnterHandler}
|
||||
onMouseLeave={onSwimlaneLeaveHandler}
|
||||
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
|
||||
>
|
||||
<SwimlaneContainer
|
||||
filterActive={filterActive}
|
||||
maskAll={
|
||||
maskAll &&
|
||||
!hasMatchingPoints({
|
||||
filteredFields,
|
||||
swimlaneData: viewBySwimlaneData,
|
||||
})
|
||||
<>
|
||||
<div
|
||||
className="mlExplorerSwimlane euiText"
|
||||
onMouseEnter={onSwimlaneEnterHandler}
|
||||
onMouseLeave={onSwimlaneLeaveHandler}
|
||||
data-test-subj="mlAnomalyExplorerSwimlaneViewBy"
|
||||
>
|
||||
<SwimlaneContainer
|
||||
filterActive={filterActive}
|
||||
maskAll={
|
||||
maskAll &&
|
||||
!hasMatchingPoints({
|
||||
filteredFields,
|
||||
swimlaneData: viewBySwimlaneData,
|
||||
})
|
||||
}
|
||||
timeBuckets={timeBuckets}
|
||||
swimlaneCellClick={swimlaneCellClick}
|
||||
swimlaneData={viewBySwimlaneData as ViewBySwimLaneData}
|
||||
swimlaneType={SWIMLANE_TYPE.VIEW_BY}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
|
||||
fromPage={viewByFromPage}
|
||||
perPage={viewByPerPage}
|
||||
swimlaneLimit={swimlaneLimit}
|
||||
onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => {
|
||||
if (perPageUpdate) {
|
||||
explorerService.setViewByPerPage(perPageUpdate);
|
||||
}
|
||||
timeBuckets={timeBuckets}
|
||||
swimlaneCellClick={swimlaneCellClick}
|
||||
swimlaneData={viewBySwimlaneData as OverallSwimlaneData}
|
||||
swimlaneType={'viewBy'}
|
||||
selection={selectedCells}
|
||||
swimlaneRenderDoneListener={swimlaneRenderDoneListener}
|
||||
onResize={(width) => explorerService.setSwimlaneContainerWidth(width)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewBySwimlaneDataLoading && <LoadingIndicator />}
|
||||
|
||||
{!showViewBySwimlane &&
|
||||
!viewBySwimlaneDataLoading &&
|
||||
typeof viewBySwimlaneFieldName === 'string' && (
|
||||
<ExplorerNoInfluencersFound
|
||||
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
|
||||
showFilterMessage={filterActive === true}
|
||||
if (fromPageUpdate) {
|
||||
explorerService.setViewByFromPage(fromPageUpdate);
|
||||
}
|
||||
}}
|
||||
isLoading={loading || viewBySwimlaneDataLoading}
|
||||
noDataWarning={
|
||||
typeof viewBySwimlaneFieldName === 'string' ? (
|
||||
viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noResultForSelectedJobsMessage"
|
||||
defaultMessage="No results found for selected {jobsCount, plural, one {job} other {jobs}}"
|
||||
values={{ jobsCount: selectedJobs?.length ?? 1 }}
|
||||
/>
|
||||
) : (
|
||||
<ExplorerNoInfluencersFound
|
||||
viewBySwimlaneFieldName={viewBySwimlaneFieldName}
|
||||
showFilterMessage={filterActive === true}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</EuiPanel>
|
||||
|
@ -380,7 +376,6 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
|
|||
}}
|
||||
jobIds={selectedJobs.map(({ id }) => id)}
|
||||
viewBy={viewBySwimlaneFieldName!}
|
||||
limit={swimlaneLimit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -1,20 +1,13 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ExplorerNoInfluencersFound snapshot 1`] = `
|
||||
<EuiEmptyPrompt
|
||||
title={
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
values={
|
||||
Object {
|
||||
"viewBySwimlaneFieldName": "field_name",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</h2>
|
||||
<FormattedMessage
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
values={
|
||||
Object {
|
||||
"viewBySwimlaneFieldName": "field_name",
|
||||
}
|
||||
}
|
||||
titleSize="xs"
|
||||
/>
|
||||
`;
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
|
||||
/*
|
||||
* React component for rendering EuiEmptyPrompt when no influencers were found.
|
||||
|
@ -15,26 +14,17 @@ import { EuiEmptyPrompt } from '@elastic/eui';
|
|||
export const ExplorerNoInfluencersFound: FC<{
|
||||
viewBySwimlaneFieldName: string;
|
||||
showFilterMessage?: boolean;
|
||||
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => (
|
||||
<EuiEmptyPrompt
|
||||
titleSize="xs"
|
||||
title={
|
||||
<h2>
|
||||
{showFilterMessage === false && (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
|
||||
values={{ viewBySwimlaneFieldName }}
|
||||
/>
|
||||
)}
|
||||
{showFilterMessage === true && (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitleFilterMessage"
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found for specified filter"
|
||||
values={{ viewBySwimlaneFieldName }}
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) =>
|
||||
showFilterMessage === false ? (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitle"
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found"
|
||||
values={{ viewBySwimlaneFieldName }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.noInfluencersFoundTitleFilterMessage"
|
||||
defaultMessage="No {viewBySwimlaneFieldName} influencers found for specified filter"
|
||||
values={{ viewBySwimlaneFieldName }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -12,8 +12,6 @@ import PropTypes from 'prop-types';
|
|||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
|
@ -27,6 +25,7 @@ import {
|
|||
EuiPageHeaderSection,
|
||||
EuiSpacer,
|
||||
EuiTitle,
|
||||
EuiLoadingContent,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { AnnotationFlyout } from '../components/annotations/annotation_flyout';
|
||||
|
@ -36,12 +35,10 @@ import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wra
|
|||
import { InfluencersList } from '../components/influencers_list';
|
||||
import { explorerService } from './explorer_dashboard_service';
|
||||
import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector';
|
||||
import { LoadingIndicator } from '../components/loading_indicator/loading_indicator';
|
||||
import { NavigationMenu } from '../components/navigation_menu';
|
||||
import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts';
|
||||
import { JobSelector } from '../components/job_selector';
|
||||
import { SelectInterval } from '../components/controls/select_interval/select_interval';
|
||||
import { limit$ } from './select_limit/select_limit';
|
||||
import { SelectSeverity } from '../components/controls/select_severity/select_severity';
|
||||
import {
|
||||
ExplorerQueryBar,
|
||||
|
@ -142,19 +139,6 @@ export class Explorer extends React.Component {
|
|||
|
||||
state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG };
|
||||
|
||||
_unsubscribeAll = new Subject();
|
||||
|
||||
componentDidMount() {
|
||||
limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unsubscribeAll.next();
|
||||
this._unsubscribeAll.complete();
|
||||
}
|
||||
|
||||
viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value);
|
||||
|
||||
// Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes
|
||||
// and will cause a syntax error when called with getKqlQueryValues
|
||||
applyFilter = (fieldName, fieldValue, action) => {
|
||||
|
@ -240,29 +224,7 @@ export class Explorer extends React.Component {
|
|||
const noJobsFound = selectedJobs === null || selectedJobs.length === 0;
|
||||
const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0;
|
||||
|
||||
if (loading === true) {
|
||||
return (
|
||||
<ExplorerPage
|
||||
jobSelectorProps={jobSelectorProps}
|
||||
noInfluencersConfigured={noInfluencersConfigured}
|
||||
influencers={influencers}
|
||||
filterActive={filterActive}
|
||||
filterPlaceHolder={filterPlaceHolder}
|
||||
filterIconTriggeredQuery={this.state.filterIconTriggeredQuery}
|
||||
indexPattern={indexPattern}
|
||||
queryString={queryString}
|
||||
updateLanguage={this.updateLanguage}
|
||||
>
|
||||
<LoadingIndicator
|
||||
label={i18n.translate('xpack.ml.explorer.loadingLabel', {
|
||||
defaultMessage: 'Loading',
|
||||
})}
|
||||
/>
|
||||
</ExplorerPage>
|
||||
);
|
||||
}
|
||||
|
||||
if (noJobsFound) {
|
||||
if (noJobsFound && !loading) {
|
||||
return (
|
||||
<ExplorerPage jobSelectorProps={jobSelectorProps}>
|
||||
<ExplorerNoJobsFound />
|
||||
|
@ -270,7 +232,7 @@ export class Explorer extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
if (noJobsFound && hasResults === false) {
|
||||
if (noJobsFound && hasResults === false && !loading) {
|
||||
return (
|
||||
<ExplorerPage jobSelectorProps={jobSelectorProps}>
|
||||
<ExplorerNoResultsFound />
|
||||
|
@ -320,7 +282,11 @@ export class Explorer extends React.Component {
|
|||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} />
|
||||
{loading ? (
|
||||
<EuiLoadingContent lines={10} />
|
||||
) : (
|
||||
<InfluencersList influencers={influencers} influencerFilter={this.applyFilter} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -352,59 +318,59 @@ export class Explorer extends React.Component {
|
|||
</>
|
||||
)}
|
||||
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.anomaliesTitle"
|
||||
defaultMessage="Anomalies"
|
||||
{loading === false && (
|
||||
<>
|
||||
<EuiTitle className="panel-title">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.explorer.anomaliesTitle"
|
||||
defaultMessage="Anomalies"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
gutterSize="l"
|
||||
responsive={true}
|
||||
className="ml-anomalies-controls"
|
||||
>
|
||||
<EuiFlexItem grow={false} style={{ width: '170px' }}>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.explorer.severityThresholdLabel', {
|
||||
defaultMessage: 'Severity threshold',
|
||||
})}
|
||||
>
|
||||
<SelectSeverity />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} style={{ width: '170px' }}>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.explorer.intervalLabel', {
|
||||
defaultMessage: 'Interval',
|
||||
})}
|
||||
>
|
||||
<SelectInterval />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
{chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && (
|
||||
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
|
||||
<EuiFormRow label="​">
|
||||
<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="​">
|
||||
<CheckboxShowCharts />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<div className="euiText explorer-charts">
|
||||
{showCharts && <ExplorerChartsContainer {...{ ...chartsData, severity }} />}
|
||||
</div>
|
||||
|
||||
<AnomaliesTable
|
||||
bounds={bounds}
|
||||
tableData={tableData}
|
||||
influencerFilter={this.applyFilter}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ExplorerPage>
|
||||
|
|
|
@ -27,9 +27,10 @@ export const EXPLORER_ACTION = {
|
|||
SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings',
|
||||
SET_SELECTED_CELLS: 'setSelectedCells',
|
||||
SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth',
|
||||
SET_SWIMLANE_LIMIT: 'setSwimlaneLimit',
|
||||
SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName',
|
||||
SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading',
|
||||
SET_VIEW_BY_PER_PAGE: 'setViewByPerPage',
|
||||
SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage',
|
||||
};
|
||||
|
||||
export const FILTER_ACTION = {
|
||||
|
@ -51,9 +52,23 @@ export const CHART_TYPE = {
|
|||
};
|
||||
|
||||
export const MAX_CATEGORY_EXAMPLES = 10;
|
||||
|
||||
/**
|
||||
* Maximum amount of top influencer to fetch.
|
||||
*/
|
||||
export const MAX_INFLUENCER_FIELD_VALUES = 10;
|
||||
export const MAX_INFLUENCER_FIELD_NAMES = 50;
|
||||
|
||||
export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', {
|
||||
defaultMessage: 'job ID',
|
||||
});
|
||||
/**
|
||||
* Hard limitation for the size of terms
|
||||
* aggregations on influencers values.
|
||||
*/
|
||||
export const ANOMALY_SWIM_LANE_HARD_LIMIT = 1000;
|
||||
|
||||
/**
|
||||
* Default page size fot the anomaly swim lane.
|
||||
*/
|
||||
export const SWIM_LANE_DEFAULT_PAGE_SIZE = 10;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { from, isObservable, Observable, Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, flatMap, map, scan, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { DeepPartial } from '../../../common/types/common';
|
||||
|
||||
|
@ -49,7 +49,9 @@ const explorerFilteredAction$ = explorerAction$.pipe(
|
|||
|
||||
// applies action and returns state
|
||||
const explorerState$: Observable<ExplorerState> = explorerFilteredAction$.pipe(
|
||||
scan(explorerReducer, getExplorerDefaultState())
|
||||
scan(explorerReducer, getExplorerDefaultState()),
|
||||
// share the last emitted value among new subscribers
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
interface ExplorerAppState {
|
||||
|
@ -59,6 +61,8 @@ interface ExplorerAppState {
|
|||
selectedTimes?: number[];
|
||||
showTopFieldValues?: boolean;
|
||||
viewByFieldName?: string;
|
||||
viewByPerPage?: number;
|
||||
viewByFromPage?: number;
|
||||
};
|
||||
mlExplorerFilter: {
|
||||
influencersFilterQuery?: unknown;
|
||||
|
@ -88,6 +92,14 @@ const explorerAppState$: Observable<ExplorerAppState> = explorerState$.pipe(
|
|||
appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName;
|
||||
}
|
||||
|
||||
if (state.viewByFromPage !== undefined) {
|
||||
appState.mlExplorerSwimlane.viewByFromPage = state.viewByFromPage;
|
||||
}
|
||||
|
||||
if (state.viewByPerPage !== undefined) {
|
||||
appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage;
|
||||
}
|
||||
|
||||
if (state.filterActive) {
|
||||
appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery;
|
||||
appState.mlExplorerFilter.filterActive = state.filterActive;
|
||||
|
@ -153,13 +165,16 @@ export const explorerService = {
|
|||
payload,
|
||||
});
|
||||
},
|
||||
setSwimlaneLimit: (payload: number) => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIMLANE_LIMIT, payload });
|
||||
},
|
||||
setViewBySwimlaneFieldName: (payload: string) => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload });
|
||||
},
|
||||
setViewBySwimlaneLoading: (payload: any) => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload });
|
||||
},
|
||||
setViewByFromPage: (payload: number) => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE, payload });
|
||||
},
|
||||
setViewByPerPage: (payload: number) => {
|
||||
explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload });
|
||||
},
|
||||
};
|
||||
|
|
|
@ -29,7 +29,7 @@ import {
|
|||
ChartTooltipService,
|
||||
ChartTooltipValue,
|
||||
} from '../components/chart_tooltip/chart_tooltip_service';
|
||||
import { OverallSwimlaneData } from './explorer_utils';
|
||||
import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils';
|
||||
|
||||
const SCSS = {
|
||||
mlDragselectDragging: 'mlDragselectDragging',
|
||||
|
@ -57,7 +57,7 @@ export interface ExplorerSwimlaneProps {
|
|||
maskAll?: boolean;
|
||||
timeBuckets: InstanceType<typeof TimeBucketsClass>;
|
||||
swimlaneCellClick?: Function;
|
||||
swimlaneData: OverallSwimlaneData;
|
||||
swimlaneData: OverallSwimlaneData | ViewBySwimLaneData;
|
||||
swimlaneType: SwimlaneType;
|
||||
selection?: {
|
||||
lanes: any[];
|
||||
|
@ -211,7 +211,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
const { swimlaneType } = this.props;
|
||||
|
||||
// This selects both overall and viewby swimlane
|
||||
const wrapper = d3.selectAll('.ml-explorer-swimlane');
|
||||
const wrapper = d3.selectAll('.mlExplorerSwimlane');
|
||||
|
||||
wrapper.selectAll('.lane-label').classed('lane-label-masked', true);
|
||||
wrapper
|
||||
|
@ -242,7 +242,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
maskIrrelevantSwimlanes(maskAll: boolean) {
|
||||
if (maskAll === true) {
|
||||
// This selects both overall and viewby swimlane
|
||||
const allSwimlanes = d3.selectAll('.ml-explorer-swimlane');
|
||||
const allSwimlanes = d3.selectAll('.mlExplorerSwimlane');
|
||||
allSwimlanes.selectAll('.lane-label').classed('lane-label-masked', true);
|
||||
allSwimlanes
|
||||
.selectAll('.sl-cell-inner,.sl-cell-inner-dragselect')
|
||||
|
@ -258,7 +258,7 @@ export class ExplorerSwimlane extends React.Component<ExplorerSwimlaneProps> {
|
|||
|
||||
clearSelection() {
|
||||
// This selects both overall and viewby swimlane
|
||||
const wrapper = d3.selectAll('.ml-explorer-swimlane');
|
||||
const wrapper = d3.selectAll('.mlExplorerSwimlane');
|
||||
|
||||
wrapper.selectAll('.lane-label').classed('lane-label-masked', false);
|
||||
wrapper.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', false);
|
||||
|
|
|
@ -8,8 +8,6 @@ import { Moment } from 'moment';
|
|||
|
||||
import { CombinedJob } from '../../../common/types/anomaly_detection_jobs';
|
||||
|
||||
import { TimeBucketsInterval } from '../util/time_buckets';
|
||||
|
||||
interface ClearedSelectedAnomaliesState {
|
||||
selectedCells: undefined;
|
||||
viewByLoadedForTimeFormatted: null;
|
||||
|
@ -35,6 +33,10 @@ export declare interface OverallSwimlaneData extends SwimlaneData {
|
|||
latest: number;
|
||||
}
|
||||
|
||||
export interface ViewBySwimLaneData extends OverallSwimlaneData {
|
||||
cardinality: number;
|
||||
}
|
||||
|
||||
export declare const getDateFormatTz: () => any;
|
||||
|
||||
export declare const getDefaultSwimlaneData: () => SwimlaneData;
|
||||
|
@ -163,22 +165,6 @@ declare interface LoadOverallDataResponse {
|
|||
overallSwimlaneData: OverallSwimlaneData;
|
||||
}
|
||||
|
||||
export declare const loadOverallData: (
|
||||
selectedJobs: ExplorerJob[],
|
||||
interval: TimeBucketsInterval,
|
||||
bounds: TimeRangeBounds
|
||||
) => Promise<LoadOverallDataResponse>;
|
||||
|
||||
export declare const loadViewBySwimlane: (
|
||||
fieldValues: string[],
|
||||
bounds: SwimlaneBounds,
|
||||
selectedJobs: ExplorerJob[],
|
||||
viewBySwimlaneFieldName: string,
|
||||
swimlaneLimit: number,
|
||||
influencersFilterQuery: any,
|
||||
noInfluencersConfigured: boolean
|
||||
) => Promise<any>;
|
||||
|
||||
export declare const loadViewByTopFieldValuesForSelectedTime: (
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
|
|
|
@ -8,11 +8,9 @@
|
|||
* utils for Anomaly Explorer.
|
||||
*/
|
||||
|
||||
import { chain, each, get, union, uniq } from 'lodash';
|
||||
import { chain, get, union, uniq } from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE,
|
||||
ANOMALIES_TABLE_DEFAULT_QUERY_SIZE,
|
||||
|
@ -27,7 +25,7 @@ import { parseInterval } from '../../../common/util/parse_interval';
|
|||
import { ml } from '../services/ml_api_service';
|
||||
import { mlJobService } from '../services/job_service';
|
||||
import { mlResultsService } from '../services/results_service';
|
||||
import { getBoundsRoundedToInterval, getTimeBucketsFromCache } from '../util/time_buckets';
|
||||
import { getTimeBucketsFromCache } from '../util/time_buckets';
|
||||
import { getTimefilter, getUiSettings } from '../util/dependency_cache';
|
||||
|
||||
import {
|
||||
|
@ -36,7 +34,6 @@ import {
|
|||
SWIMLANE_TYPE,
|
||||
VIEW_BY_JOB_LABEL,
|
||||
} from './explorer_constants';
|
||||
import { getSwimlaneContainerWidth } from './legacy_utils';
|
||||
|
||||
// create new job objects based on standard job config objects
|
||||
// new job objects just contain job id, bucket span in seconds and a selected flag.
|
||||
|
@ -51,6 +48,7 @@ export function getClearedSelectedAnomaliesState() {
|
|||
return {
|
||||
selectedCells: undefined,
|
||||
viewByLoadedForTimeFormatted: null,
|
||||
swimlaneLimit: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -267,58 +265,6 @@ export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth)
|
|||
return buckets.getInterval();
|
||||
}
|
||||
|
||||
export function loadViewByTopFieldValuesForSelectedTime(
|
||||
earliestMs,
|
||||
latestMs,
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
noInfluencersConfigured
|
||||
) {
|
||||
const selectedJobIds = selectedJobs.map((d) => d.id);
|
||||
|
||||
// Find the top field values for the selected time, and then load the 'view by'
|
||||
// swimlane over the full time range for those specific field values.
|
||||
return new Promise((resolve) => {
|
||||
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
|
||||
mlResultsService
|
||||
.getTopInfluencers(selectedJobIds, earliestMs, latestMs, swimlaneLimit)
|
||||
.then((resp) => {
|
||||
if (resp.influencers[viewBySwimlaneFieldName] === undefined) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
const topFieldValues = [];
|
||||
const topInfluencers = resp.influencers[viewBySwimlaneFieldName];
|
||||
if (Array.isArray(topInfluencers)) {
|
||||
topInfluencers.forEach((influencerData) => {
|
||||
if (influencerData.maxAnomalyScore > 0) {
|
||||
topFieldValues.push(influencerData.influencerFieldValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
resolve(topFieldValues);
|
||||
});
|
||||
} else {
|
||||
mlResultsService
|
||||
.getScoresByBucket(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
getSwimlaneBucketInterval(
|
||||
selectedJobs,
|
||||
getSwimlaneContainerWidth(noInfluencersConfigured)
|
||||
).asSeconds() + 's',
|
||||
swimlaneLimit
|
||||
)
|
||||
.then((resp) => {
|
||||
const topFieldValues = Object.keys(resp.results);
|
||||
resolve(topFieldValues);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName
|
||||
export function getViewBySwimlaneOptions({
|
||||
currentViewBySwimlaneFieldName,
|
||||
|
@ -435,105 +381,6 @@ export function getViewBySwimlaneOptions({
|
|||
};
|
||||
}
|
||||
|
||||
export function processOverallResults(scoresByTime, searchBounds, interval) {
|
||||
const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', {
|
||||
defaultMessage: 'Overall',
|
||||
});
|
||||
const dataset = {
|
||||
laneLabels: [overallLabel],
|
||||
points: [],
|
||||
interval,
|
||||
earliest: searchBounds.min.valueOf() / 1000,
|
||||
latest: searchBounds.max.valueOf() / 1000,
|
||||
};
|
||||
|
||||
if (Object.keys(scoresByTime).length > 0) {
|
||||
// Store the earliest and latest times of the data returned by the ES aggregations,
|
||||
// These will be used for calculating the earliest and latest times for the swimlane charts.
|
||||
each(scoresByTime, (score, timeMs) => {
|
||||
const time = timeMs / 1000;
|
||||
dataset.points.push({
|
||||
laneLabel: overallLabel,
|
||||
time,
|
||||
value: score,
|
||||
});
|
||||
|
||||
dataset.earliest = Math.min(time, dataset.earliest);
|
||||
dataset.latest = Math.max(time + dataset.interval, dataset.latest);
|
||||
});
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
export function processViewByResults(
|
||||
scoresByInfluencerAndTime,
|
||||
sortedLaneValues,
|
||||
bounds,
|
||||
viewBySwimlaneFieldName,
|
||||
interval
|
||||
) {
|
||||
// Processes the scores for the 'view by' swimlane.
|
||||
// Sorts the lanes according to the supplied array of lane
|
||||
// values in the order in which they should be displayed,
|
||||
// or pass an empty array to sort lanes according to max score over all time.
|
||||
const dataset = {
|
||||
fieldName: viewBySwimlaneFieldName,
|
||||
points: [],
|
||||
interval,
|
||||
};
|
||||
|
||||
// Set the earliest and latest to be the same as the overall swimlane.
|
||||
dataset.earliest = bounds.earliest;
|
||||
dataset.latest = bounds.latest;
|
||||
|
||||
const laneLabels = [];
|
||||
const maxScoreByLaneLabel = {};
|
||||
|
||||
each(scoresByInfluencerAndTime, (influencerData, influencerFieldValue) => {
|
||||
laneLabels.push(influencerFieldValue);
|
||||
maxScoreByLaneLabel[influencerFieldValue] = 0;
|
||||
|
||||
each(influencerData, (anomalyScore, timeMs) => {
|
||||
const time = timeMs / 1000;
|
||||
dataset.points.push({
|
||||
laneLabel: influencerFieldValue,
|
||||
time,
|
||||
value: anomalyScore,
|
||||
});
|
||||
maxScoreByLaneLabel[influencerFieldValue] = Math.max(
|
||||
maxScoreByLaneLabel[influencerFieldValue],
|
||||
anomalyScore
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const sortValuesLength = sortedLaneValues.length;
|
||||
if (sortValuesLength === 0) {
|
||||
// Sort lanes in descending order of max score.
|
||||
// Note the keys in scoresByInfluencerAndTime received from the ES request
|
||||
// are not guaranteed to be sorted by score if they can be parsed as numbers
|
||||
// (e.g. if viewing by HTTP response code).
|
||||
dataset.laneLabels = laneLabels.sort((a, b) => {
|
||||
return maxScoreByLaneLabel[b] - maxScoreByLaneLabel[a];
|
||||
});
|
||||
} else {
|
||||
// Sort lanes according to supplied order
|
||||
// e.g. when a cell in the overall swimlane has been selected.
|
||||
// Find the index of each lane label from the actual data set,
|
||||
// rather than using sortedLaneValues as-is, just in case they differ.
|
||||
dataset.laneLabels = laneLabels.sort((a, b) => {
|
||||
let aIndex = sortedLaneValues.indexOf(a);
|
||||
let bIndex = sortedLaneValues.indexOf(b);
|
||||
aIndex = aIndex > -1 ? aIndex : sortValuesLength;
|
||||
bIndex = bIndex > -1 ? bIndex : sortValuesLength;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) {
|
||||
const jobIds =
|
||||
selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL
|
||||
|
@ -723,138 +570,6 @@ export async function loadDataForCharts(
|
|||
});
|
||||
}
|
||||
|
||||
export function loadOverallData(selectedJobs, interval, bounds) {
|
||||
return new Promise((resolve) => {
|
||||
// Loads the overall data components i.e. the overall swimlane and influencers list.
|
||||
if (selectedJobs === null) {
|
||||
resolve({
|
||||
loading: false,
|
||||
hasResuts: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the search bounds align to the bucketing interval used in the swimlane so
|
||||
// that the first and last buckets are complete.
|
||||
const searchBounds = getBoundsRoundedToInterval(bounds, interval, false);
|
||||
const selectedJobIds = selectedJobs.map((d) => d.id);
|
||||
|
||||
// Load the overall bucket scores by time.
|
||||
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
|
||||
// which wouldn't be the case if e.g. '1M' was used.
|
||||
// Pass 'true' when obtaining bucket bounds due to the way the overall_buckets endpoint works
|
||||
// to ensure the search is inclusive of end time.
|
||||
const overallBucketsBounds = getBoundsRoundedToInterval(bounds, interval, true);
|
||||
mlResultsService
|
||||
.getOverallBucketScores(
|
||||
selectedJobIds,
|
||||
// Note there is an optimization for when top_n == 1.
|
||||
// If top_n > 1, we should test what happens when the request takes long
|
||||
// and refactor the loading calls, if necessary, to avoid delays in loading other components.
|
||||
1,
|
||||
overallBucketsBounds.min.valueOf(),
|
||||
overallBucketsBounds.max.valueOf(),
|
||||
interval.asSeconds() + 's'
|
||||
)
|
||||
.then((resp) => {
|
||||
const overallSwimlaneData = processOverallResults(
|
||||
resp.results,
|
||||
searchBounds,
|
||||
interval.asSeconds()
|
||||
);
|
||||
|
||||
resolve({
|
||||
loading: false,
|
||||
overallSwimlaneData,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function loadViewBySwimlane(
|
||||
fieldValues,
|
||||
bounds,
|
||||
selectedJobs,
|
||||
viewBySwimlaneFieldName,
|
||||
swimlaneLimit,
|
||||
influencersFilterQuery,
|
||||
noInfluencersConfigured
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
const finish = (resp) => {
|
||||
if (resp !== undefined) {
|
||||
const viewBySwimlaneData = processViewByResults(
|
||||
resp.results,
|
||||
fieldValues,
|
||||
bounds,
|
||||
viewBySwimlaneFieldName,
|
||||
getSwimlaneBucketInterval(
|
||||
selectedJobs,
|
||||
getSwimlaneContainerWidth(noInfluencersConfigured)
|
||||
).asSeconds()
|
||||
);
|
||||
|
||||
resolve({
|
||||
viewBySwimlaneData,
|
||||
viewBySwimlaneDataLoading: false,
|
||||
});
|
||||
} else {
|
||||
resolve({ viewBySwimlaneDataLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedJobs === undefined || viewBySwimlaneFieldName === undefined) {
|
||||
finish();
|
||||
return;
|
||||
} else {
|
||||
// Ensure the search bounds align to the bucketing interval used in the swimlane so
|
||||
// that the first and last buckets are complete.
|
||||
const timefilter = getTimefilter();
|
||||
const timefilterBounds = timefilter.getActiveBounds();
|
||||
const searchBounds = getBoundsRoundedToInterval(
|
||||
timefilterBounds,
|
||||
getSwimlaneBucketInterval(selectedJobs, getSwimlaneContainerWidth(noInfluencersConfigured)),
|
||||
false
|
||||
);
|
||||
const selectedJobIds = selectedJobs.map((d) => d.id);
|
||||
|
||||
// load scores by influencer/jobId value and time.
|
||||
// Pass the interval in seconds as the swimlane relies on a fixed number of seconds between buckets
|
||||
// which wouldn't be the case if e.g. '1M' was used.
|
||||
const interval = `${getSwimlaneBucketInterval(
|
||||
selectedJobs,
|
||||
getSwimlaneContainerWidth(noInfluencersConfigured)
|
||||
).asSeconds()}s`;
|
||||
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
|
||||
mlResultsService
|
||||
.getInfluencerValueMaxScoreByTime(
|
||||
selectedJobIds,
|
||||
viewBySwimlaneFieldName,
|
||||
fieldValues,
|
||||
searchBounds.min.valueOf(),
|
||||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit,
|
||||
influencersFilterQuery
|
||||
)
|
||||
.then(finish);
|
||||
} else {
|
||||
const jobIds =
|
||||
fieldValues !== undefined && fieldValues.length > 0 ? fieldValues : selectedJobIds;
|
||||
mlResultsService
|
||||
.getScoresByBucket(
|
||||
jobIds,
|
||||
searchBounds.min.valueOf(),
|
||||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit
|
||||
)
|
||||
.then(finish);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadTopInfluencers(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
|
@ -871,6 +586,8 @@ export async function loadTopInfluencers(
|
|||
earliestMs,
|
||||
latestMs,
|
||||
MAX_INFLUENCER_FIELD_VALUES,
|
||||
10,
|
||||
1,
|
||||
influencers,
|
||||
influencersFilterQuery
|
||||
)
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useUrlState } from '../../util/url_state';
|
||||
import { SWIMLANE_TYPE } from '../explorer_constants';
|
||||
import { AppStateSelectedCells } from '../explorer_utils';
|
||||
|
@ -14,55 +15,55 @@ export const useSelectedCells = (): [
|
|||
] => {
|
||||
const [appState, setAppState] = useUrlState('_a');
|
||||
|
||||
let selectedCells: AppStateSelectedCells | undefined;
|
||||
|
||||
// keep swimlane selection, restore selectedCells from AppState
|
||||
if (
|
||||
appState &&
|
||||
appState.mlExplorerSwimlane &&
|
||||
appState.mlExplorerSwimlane.selectedType !== undefined
|
||||
) {
|
||||
selectedCells = {
|
||||
type: appState.mlExplorerSwimlane.selectedType,
|
||||
lanes: appState.mlExplorerSwimlane.selectedLanes,
|
||||
times: appState.mlExplorerSwimlane.selectedTimes,
|
||||
showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues,
|
||||
viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName,
|
||||
};
|
||||
}
|
||||
const selectedCells = useMemo(() => {
|
||||
return appState?.mlExplorerSwimlane?.selectedType !== undefined
|
||||
? {
|
||||
type: appState.mlExplorerSwimlane.selectedType,
|
||||
lanes: appState.mlExplorerSwimlane.selectedLanes,
|
||||
times: appState.mlExplorerSwimlane.selectedTimes,
|
||||
showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues,
|
||||
viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName,
|
||||
}
|
||||
: undefined;
|
||||
// TODO fix appState to use memoization
|
||||
}, [JSON.stringify(appState?.mlExplorerSwimlane)]);
|
||||
|
||||
const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => {
|
||||
const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane };
|
||||
const setSelectedCells = useCallback(
|
||||
(swimlaneSelectedCells: AppStateSelectedCells) => {
|
||||
const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane };
|
||||
|
||||
if (swimlaneSelectedCells !== undefined) {
|
||||
swimlaneSelectedCells.showTopFieldValues = false;
|
||||
if (swimlaneSelectedCells !== undefined) {
|
||||
swimlaneSelectedCells.showTopFieldValues = false;
|
||||
|
||||
const currentSwimlaneType = selectedCells?.type;
|
||||
const currentShowTopFieldValues = selectedCells?.showTopFieldValues;
|
||||
const newSwimlaneType = swimlaneSelectedCells?.type;
|
||||
const currentSwimlaneType = selectedCells?.type;
|
||||
const currentShowTopFieldValues = selectedCells?.showTopFieldValues;
|
||||
const newSwimlaneType = swimlaneSelectedCells?.type;
|
||||
|
||||
if (
|
||||
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
|
||||
newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
|
||||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
|
||||
currentShowTopFieldValues === true
|
||||
) {
|
||||
swimlaneSelectedCells.showTopFieldValues = true;
|
||||
if (
|
||||
(currentSwimlaneType === SWIMLANE_TYPE.OVERALL &&
|
||||
newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) ||
|
||||
newSwimlaneType === SWIMLANE_TYPE.OVERALL ||
|
||||
currentShowTopFieldValues === true
|
||||
) {
|
||||
swimlaneSelectedCells.showTopFieldValues = true;
|
||||
}
|
||||
|
||||
mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
|
||||
mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
|
||||
mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
|
||||
mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
|
||||
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
|
||||
} else {
|
||||
delete mlExplorerSwimlane.selectedType;
|
||||
delete mlExplorerSwimlane.selectedLanes;
|
||||
delete mlExplorerSwimlane.selectedTimes;
|
||||
delete mlExplorerSwimlane.showTopFieldValues;
|
||||
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
|
||||
}
|
||||
|
||||
mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type;
|
||||
mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes;
|
||||
mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times;
|
||||
mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues;
|
||||
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
|
||||
} else {
|
||||
delete mlExplorerSwimlane.selectedType;
|
||||
delete mlExplorerSwimlane.selectedLanes;
|
||||
delete mlExplorerSwimlane.selectedTimes;
|
||||
delete mlExplorerSwimlane.showTopFieldValues;
|
||||
setAppState('mlExplorerSwimlane', mlExplorerSwimlane);
|
||||
}
|
||||
};
|
||||
},
|
||||
[appState?.mlExplorerSwimlane, selectedCells]
|
||||
);
|
||||
|
||||
return [selectedCells, setSelectedCells];
|
||||
};
|
||||
|
|
|
@ -11,8 +11,3 @@ export function getChartContainerWidth() {
|
|||
const chartContainer = document.querySelector('.explorer-charts');
|
||||
return Math.floor((chartContainer && chartContainer.clientWidth) || 0);
|
||||
}
|
||||
|
||||
export function getSwimlaneContainerWidth() {
|
||||
const explorerContainer = document.querySelector('.ml-explorer');
|
||||
return (explorerContainer && explorerContainer.clientWidth) || 0;
|
||||
}
|
||||
|
|
|
@ -19,5 +19,6 @@ export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerSta
|
|||
queryString: '',
|
||||
tableQueryString: '',
|
||||
...getClearedSelectedAnomaliesState(),
|
||||
viewByFromPage: 1,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload)
|
|||
noInfluencersConfigured: getInfluencers(selectedJobs).length === 0,
|
||||
overallSwimlaneData: getDefaultSwimlaneData(),
|
||||
selectedJobs,
|
||||
viewByFromPage: 1,
|
||||
};
|
||||
|
||||
// clear filter if selected jobs have no influencers
|
||||
|
|
|
@ -27,7 +27,7 @@ import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder';
|
|||
export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => {
|
||||
const { type, payload } = nextAction;
|
||||
|
||||
let nextState;
|
||||
let nextState: ExplorerState;
|
||||
|
||||
switch (type) {
|
||||
case EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS:
|
||||
|
@ -39,6 +39,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
...state,
|
||||
...getClearedSelectedAnomaliesState(),
|
||||
loading: false,
|
||||
viewByFromPage: 1,
|
||||
selectedJobs: [],
|
||||
};
|
||||
break;
|
||||
|
@ -82,22 +83,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH:
|
||||
if (state.noInfluencersConfigured === true) {
|
||||
// swimlane is full width, minus 30 for the 'no influencers' info icon,
|
||||
// minus 170 for the lane labels, minus 50 padding
|
||||
nextState = { ...state, swimlaneContainerWidth: payload - 250 };
|
||||
} else {
|
||||
// swimlane width is 5 sixths of the window,
|
||||
// minus 170 for the lane labels, minus 50 padding
|
||||
nextState = { ...state, swimlaneContainerWidth: (payload / 6) * 5 - 220 };
|
||||
}
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_SWIMLANE_LIMIT:
|
||||
nextState = {
|
||||
...state,
|
||||
swimlaneLimit: payload,
|
||||
};
|
||||
nextState = { ...state, swimlaneContainerWidth: payload };
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME:
|
||||
|
@ -117,6 +103,9 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
...getClearedSelectedAnomaliesState(),
|
||||
maskAll,
|
||||
viewBySwimlaneFieldName,
|
||||
viewBySwimlaneData: getDefaultSwimlaneData(),
|
||||
viewByFromPage: 1,
|
||||
viewBySwimlaneDataLoading: true,
|
||||
};
|
||||
break;
|
||||
|
||||
|
@ -125,7 +114,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
nextState = {
|
||||
...state,
|
||||
annotationsData,
|
||||
...overallState,
|
||||
overallSwimlaneData: overallState,
|
||||
tableData,
|
||||
viewBySwimlaneData: {
|
||||
...getDefaultSwimlaneData(),
|
||||
|
@ -134,6 +123,22 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
};
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE:
|
||||
nextState = {
|
||||
...state,
|
||||
viewByFromPage: payload,
|
||||
};
|
||||
break;
|
||||
|
||||
case EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE:
|
||||
nextState = {
|
||||
...state,
|
||||
// reset current page on the page size change
|
||||
viewByFromPage: 1,
|
||||
viewByPerPage: payload,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
nextState = state;
|
||||
}
|
||||
|
@ -155,7 +160,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo
|
|||
filteredFields: nextState.filteredFields,
|
||||
isAndOperator: nextState.isAndOperator,
|
||||
selectedJobs: nextState.selectedJobs,
|
||||
selectedCells: nextState.selectedCells,
|
||||
selectedCells: nextState.selectedCells!,
|
||||
});
|
||||
|
||||
const { bounds, selectedCells } = nextState;
|
||||
|
|
|
@ -57,5 +57,6 @@ export function setInfluencerFilterSettings(
|
|||
filteredFields.includes(selectedViewByFieldName) === false,
|
||||
viewBySwimlaneFieldName: selectedViewByFieldName,
|
||||
viewBySwimlaneOptions: filteredViewBySwimlaneOptions,
|
||||
viewByFromPage: 1,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -19,7 +19,9 @@ import {
|
|||
TimeRangeBounds,
|
||||
OverallSwimlaneData,
|
||||
SwimlaneData,
|
||||
ViewBySwimLaneData,
|
||||
} from '../../explorer_utils';
|
||||
import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants';
|
||||
|
||||
export interface ExplorerState {
|
||||
annotationsData: any[];
|
||||
|
@ -42,14 +44,16 @@ export interface ExplorerState {
|
|||
selectedJobs: ExplorerJob[] | null;
|
||||
swimlaneBucketInterval: any;
|
||||
swimlaneContainerWidth: number;
|
||||
swimlaneLimit: number;
|
||||
tableData: AnomaliesTableData;
|
||||
tableQueryString: string;
|
||||
viewByLoadedForTimeFormatted: string | null;
|
||||
viewBySwimlaneData: SwimlaneData | OverallSwimlaneData;
|
||||
viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData;
|
||||
viewBySwimlaneDataLoading: boolean;
|
||||
viewBySwimlaneFieldName?: string;
|
||||
viewByPerPage: number;
|
||||
viewByFromPage: number;
|
||||
viewBySwimlaneOptions: string[];
|
||||
swimlaneLimit?: number;
|
||||
}
|
||||
|
||||
function getDefaultIndexPattern() {
|
||||
|
@ -78,7 +82,6 @@ export function getExplorerDefaultState(): ExplorerState {
|
|||
selectedJobs: null,
|
||||
swimlaneBucketInterval: undefined,
|
||||
swimlaneContainerWidth: 0,
|
||||
swimlaneLimit: 10,
|
||||
tableData: {
|
||||
anomalies: [],
|
||||
examplesByJobId: [''],
|
||||
|
@ -92,5 +95,8 @@ export function getExplorerDefaultState(): ExplorerState {
|
|||
viewBySwimlaneDataLoading: false,
|
||||
viewBySwimlaneFieldName: undefined,
|
||||
viewBySwimlaneOptions: [],
|
||||
viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
viewByFromPage: 1,
|
||||
swimlaneLimit: undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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} />;
|
||||
};
|
|
@ -5,7 +5,14 @@
|
|||
*/
|
||||
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import { EuiResizeObserver, EuiText } from '@elastic/eui';
|
||||
import {
|
||||
EuiText,
|
||||
EuiLoadingChart,
|
||||
EuiResizeObserver,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiEmptyPrompt,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { throttle } from 'lodash';
|
||||
import {
|
||||
|
@ -14,48 +21,139 @@ import {
|
|||
} from '../../application/explorer/explorer_swimlane';
|
||||
|
||||
import { MlTooltipComponent } from '../../application/components/chart_tooltip';
|
||||
import { SwimLanePagination } from './swimlane_pagination';
|
||||
import { SWIMLANE_TYPE } from './explorer_constants';
|
||||
import { ViewBySwimLaneData } from './explorer_utils';
|
||||
|
||||
/**
|
||||
* Ignore insignificant resize, e.g. browser scrollbar appearance.
|
||||
*/
|
||||
const RESIZE_IGNORED_DIFF_PX = 20;
|
||||
const RESIZE_THROTTLE_TIME_MS = 500;
|
||||
|
||||
export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData {
|
||||
return arg && arg.hasOwnProperty('cardinality');
|
||||
}
|
||||
|
||||
/**
|
||||
* Anomaly swim lane container responsible for handling resizing, pagination and injecting
|
||||
* tooltip service.
|
||||
*
|
||||
* @param children
|
||||
* @param onResize
|
||||
* @param perPage
|
||||
* @param fromPage
|
||||
* @param swimlaneLimit
|
||||
* @param onPaginationChange
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
export const SwimlaneContainer: FC<
|
||||
Omit<ExplorerSwimlaneProps, 'chartWidth' | 'tooltipService'> & {
|
||||
onResize: (width: number) => void;
|
||||
fromPage?: number;
|
||||
perPage?: number;
|
||||
swimlaneLimit?: number;
|
||||
onPaginationChange?: (arg: { perPage?: number; fromPage?: number }) => void;
|
||||
isLoading: boolean;
|
||||
noDataWarning: string | JSX.Element | null;
|
||||
}
|
||||
> = ({ children, onResize, ...props }) => {
|
||||
> = ({
|
||||
children,
|
||||
onResize,
|
||||
perPage,
|
||||
fromPage,
|
||||
swimlaneLimit,
|
||||
onPaginationChange,
|
||||
isLoading,
|
||||
noDataWarning,
|
||||
...props
|
||||
}) => {
|
||||
const [chartWidth, setChartWidth] = useState<number>(0);
|
||||
|
||||
const resizeHandler = useCallback(
|
||||
throttle((e: { width: number; height: number }) => {
|
||||
const labelWidth = 200;
|
||||
setChartWidth(e.width - labelWidth);
|
||||
onResize(e.width);
|
||||
const resultNewWidth = e.width - labelWidth;
|
||||
if (Math.abs(resultNewWidth - chartWidth) > RESIZE_IGNORED_DIFF_PX) {
|
||||
setChartWidth(resultNewWidth);
|
||||
onResize(resultNewWidth);
|
||||
}
|
||||
}, RESIZE_THROTTLE_TIME_MS),
|
||||
[]
|
||||
[chartWidth]
|
||||
);
|
||||
|
||||
const showSwimlane =
|
||||
props.swimlaneData &&
|
||||
props.swimlaneData.laneLabels &&
|
||||
props.swimlaneData.laneLabels.length > 0 &&
|
||||
props.swimlaneData.points.length > 0;
|
||||
|
||||
const isPaginationVisible =
|
||||
(showSwimlane || isLoading) &&
|
||||
swimlaneLimit !== undefined &&
|
||||
onPaginationChange &&
|
||||
props.swimlaneType === SWIMLANE_TYPE.VIEW_BY &&
|
||||
fromPage &&
|
||||
perPage;
|
||||
|
||||
return (
|
||||
<EuiResizeObserver onResize={resizeHandler}>
|
||||
{(resizeRef) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
resizeRef(el);
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<EuiText color="subdued" size="s">
|
||||
<MlTooltipComponent>
|
||||
{(tooltipService) => (
|
||||
<ExplorerSwimlane
|
||||
chartWidth={chartWidth}
|
||||
tooltipService={tooltipService}
|
||||
{...props}
|
||||
<>
|
||||
<EuiResizeObserver onResize={resizeHandler}>
|
||||
{(resizeRef) => (
|
||||
<EuiFlexGroup
|
||||
gutterSize={'none'}
|
||||
direction={'column'}
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
ref={(el) => {
|
||||
resizeRef(el);
|
||||
}}
|
||||
data-test-subj="mlSwimLaneContainer"
|
||||
>
|
||||
<EuiFlexItem style={{ width: '100%', overflowY: 'auto' }} grow={false}>
|
||||
<EuiText color="subdued" size="s">
|
||||
{showSwimlane && !isLoading && (
|
||||
<MlTooltipComponent>
|
||||
{(tooltipService) => (
|
||||
<ExplorerSwimlane
|
||||
chartWidth={chartWidth}
|
||||
tooltipService={tooltipService}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</MlTooltipComponent>
|
||||
)}
|
||||
{isLoading && (
|
||||
<EuiText textAlign={'center'}>
|
||||
<EuiLoadingChart
|
||||
size="xl"
|
||||
mono={true}
|
||||
data-test-subj="mlSwimLaneLoadingIndicator"
|
||||
/>
|
||||
</EuiText>
|
||||
)}
|
||||
{!isLoading && !showSwimlane && (
|
||||
<EuiEmptyPrompt
|
||||
titleSize="xs"
|
||||
style={{ padding: 0 }}
|
||||
title={<h2>{noDataWarning}</h2>}
|
||||
/>
|
||||
)}
|
||||
</MlTooltipComponent>
|
||||
</EuiText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EuiResizeObserver>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{isPaginationVisible && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<SwimLanePagination
|
||||
cardinality={swimlaneLimit!}
|
||||
fromPage={fromPage!}
|
||||
perPage={perPage!}
|
||||
onPaginationChange={onPaginationChange!}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiResizeObserver>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -22,7 +22,6 @@ import { ml } from '../../services/ml_api_service';
|
|||
import { useExplorerData } from '../../explorer/actions';
|
||||
import { explorerService } from '../../explorer/explorer_dashboard_service';
|
||||
import { getDateFormatTz } from '../../explorer/explorer_utils';
|
||||
import { useSwimlaneLimit } from '../../explorer/select_limit';
|
||||
import { useJobSelection } from '../../components/job_selector/use_job_selection';
|
||||
import { useShowCharts } from '../../components/controls/checkbox_showcharts';
|
||||
import { useTableInterval } from '../../components/controls/select_interval';
|
||||
|
@ -30,6 +29,7 @@ import { useTableSeverity } from '../../components/controls/select_severity';
|
|||
import { useUrlState } from '../../util/url_state';
|
||||
import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs';
|
||||
import { useTimefilter } from '../../contexts/kibana';
|
||||
import { isViewBySwimLaneData } from '../../explorer/swimlane_container';
|
||||
|
||||
const breadcrumbs = [
|
||||
ML_BREADCRUMB,
|
||||
|
@ -151,10 +151,6 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
|
|||
const [showCharts] = useShowCharts();
|
||||
const [tableInterval] = useTableInterval();
|
||||
const [tableSeverity] = useTableSeverity();
|
||||
const [swimlaneLimit] = useSwimlaneLimit();
|
||||
useEffect(() => {
|
||||
explorerService.setSwimlaneLimit(swimlaneLimit);
|
||||
}, [swimlaneLimit]);
|
||||
|
||||
const [selectedCells, setSelectedCells] = useSelectedCells();
|
||||
useEffect(() => {
|
||||
|
@ -170,14 +166,26 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim
|
|||
selectedCells,
|
||||
selectedJobs: explorerState.selectedJobs,
|
||||
swimlaneBucketInterval: explorerState.swimlaneBucketInterval,
|
||||
swimlaneLimit: explorerState.swimlaneLimit,
|
||||
tableInterval: tableInterval.val,
|
||||
tableSeverity: tableSeverity.val,
|
||||
viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName,
|
||||
swimlaneContainerWidth: explorerState.swimlaneContainerWidth,
|
||||
viewByPerPage: explorerState.viewByPerPage,
|
||||
viewByFromPage: explorerState.viewByFromPage,
|
||||
}) ||
|
||||
undefined;
|
||||
|
||||
useEffect(() => {
|
||||
loadExplorerData(loadExplorerDataConfig);
|
||||
if (explorerState && explorerState.swimlaneContainerWidth > 0) {
|
||||
loadExplorerData({
|
||||
...loadExplorerDataConfig,
|
||||
swimlaneLimit:
|
||||
explorerState?.viewBySwimlaneData &&
|
||||
isViewBySwimLaneData(explorerState?.viewBySwimlaneData)
|
||||
? explorerState?.viewBySwimlaneData.cardinality
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}, [JSON.stringify(loadExplorerDataConfig)]);
|
||||
|
||||
if (explorerState === undefined || refresh === undefined || showCharts === undefined) {
|
||||
|
|
|
@ -12,41 +12,47 @@ import { I18nProvider } from '@kbn/i18n/react';
|
|||
|
||||
import { TimeSeriesExplorerUrlStateManager } from './timeseriesexplorer';
|
||||
|
||||
jest.mock('../../contexts/kibana/kibana_context', () => ({
|
||||
useMlKibana: () => {
|
||||
return {
|
||||
services: {
|
||||
uiSettings: { get: jest.fn() },
|
||||
data: {
|
||||
query: {
|
||||
timefilter: {
|
||||
jest.mock('../../contexts/kibana/kibana_context', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { of } = require('rxjs');
|
||||
return {
|
||||
useMlKibana: () => {
|
||||
return {
|
||||
services: {
|
||||
uiSettings: { get: jest.fn() },
|
||||
data: {
|
||||
query: {
|
||||
timefilter: {
|
||||
disableTimeRangeSelector: jest.fn(),
|
||||
disableAutoRefreshSelector: jest.fn(),
|
||||
enableTimeRangeSelector: jest.fn(),
|
||||
enableAutoRefreshSelector: jest.fn(),
|
||||
getRefreshInterval: jest.fn(),
|
||||
setRefreshInterval: jest.fn(),
|
||||
getTime: jest.fn(),
|
||||
isAutoRefreshSelectorEnabled: jest.fn(),
|
||||
isTimeRangeSelectorEnabled: jest.fn(),
|
||||
getRefreshIntervalUpdate$: jest.fn(),
|
||||
getTimeUpdate$: jest.fn(),
|
||||
getEnabledUpdated$: jest.fn(),
|
||||
timefilter: {
|
||||
disableTimeRangeSelector: jest.fn(),
|
||||
disableAutoRefreshSelector: jest.fn(),
|
||||
enableTimeRangeSelector: jest.fn(),
|
||||
enableAutoRefreshSelector: jest.fn(),
|
||||
getRefreshInterval: jest.fn(),
|
||||
setRefreshInterval: jest.fn(),
|
||||
getTime: jest.fn(),
|
||||
isAutoRefreshSelectorEnabled: jest.fn(),
|
||||
isTimeRangeSelectorEnabled: jest.fn(),
|
||||
getRefreshIntervalUpdate$: jest.fn(),
|
||||
getTimeUpdate$: jest.fn(() => {
|
||||
return of();
|
||||
}),
|
||||
getEnabledUpdated$: jest.fn(),
|
||||
},
|
||||
history: { get: jest.fn() },
|
||||
},
|
||||
history: { get: jest.fn() },
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addDanger: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addDanger: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../util/dependency_cache', () => ({
|
||||
getToastNotifications: () => ({ addSuccess: jest.fn(), addDanger: jest.fn() }),
|
||||
|
|
|
@ -5,26 +5,40 @@
|
|||
*/
|
||||
|
||||
import { useObservable } from 'react-use';
|
||||
import { merge, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { merge } from 'rxjs';
|
||||
import { map, skip } from 'rxjs/operators';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { annotationsRefresh$ } from '../services/annotations_service';
|
||||
import {
|
||||
mlTimefilterRefresh$,
|
||||
mlTimefilterTimeChange$,
|
||||
} from '../services/timefilter_refresh_service';
|
||||
import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service';
|
||||
import { useTimefilter } from '../contexts/kibana';
|
||||
|
||||
export interface Refresh {
|
||||
lastRefresh: number;
|
||||
timeRange?: { start: string; end: string };
|
||||
}
|
||||
|
||||
const refresh$: Observable<Refresh> = merge(
|
||||
mlTimefilterRefresh$,
|
||||
mlTimefilterTimeChange$,
|
||||
annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d })))
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook that provides the latest refresh timestamp
|
||||
* and the most recent applied time range.
|
||||
*/
|
||||
export const useRefresh = () => {
|
||||
const timefilter = useTimefilter();
|
||||
|
||||
const refresh$ = useMemo(() => {
|
||||
return merge(
|
||||
mlTimefilterRefresh$,
|
||||
timefilter.getTimeUpdate$().pipe(
|
||||
// skip initially emitted value
|
||||
skip(1),
|
||||
map((_) => {
|
||||
const { from, to } = timefilter.getTime();
|
||||
return { lastRefresh: Date.now(), timeRange: { start: from, end: to } };
|
||||
})
|
||||
),
|
||||
annotationsRefresh$.pipe(map((d) => ({ lastRefresh: d })))
|
||||
);
|
||||
}, []);
|
||||
|
||||
return useObservable<Refresh>(refresh$);
|
||||
};
|
||||
|
|
|
@ -12,14 +12,19 @@ import {
|
|||
UI_SETTINGS,
|
||||
} from '../../../../../../src/plugins/data/public';
|
||||
import { getBoundsRoundedToInterval, TimeBuckets, TimeRangeBounds } from '../util/time_buckets';
|
||||
import { ExplorerJob, OverallSwimlaneData, SwimlaneData } from '../explorer/explorer_utils';
|
||||
import {
|
||||
ExplorerJob,
|
||||
OverallSwimlaneData,
|
||||
SwimlaneData,
|
||||
ViewBySwimLaneData,
|
||||
} from '../explorer/explorer_utils';
|
||||
import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants';
|
||||
import { MlResultsService } from './results_service';
|
||||
|
||||
/**
|
||||
* Anomaly Explorer Service
|
||||
* Service for retrieving anomaly swim lanes data.
|
||||
*/
|
||||
export class ExplorerService {
|
||||
export class AnomalyTimelineService {
|
||||
private timeBuckets: TimeBuckets;
|
||||
private _customTimeRange: TimeRange | undefined;
|
||||
|
||||
|
@ -130,12 +135,27 @@ export class ExplorerService {
|
|||
return overallSwimlaneData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches view by swim lane data.
|
||||
*
|
||||
* @param fieldValues
|
||||
* @param bounds
|
||||
* @param selectedJobs
|
||||
* @param viewBySwimlaneFieldName
|
||||
* @param swimlaneLimit
|
||||
* @param perPage
|
||||
* @param fromPage
|
||||
* @param swimlaneContainerWidth
|
||||
* @param influencersFilterQuery
|
||||
*/
|
||||
public async loadViewBySwimlane(
|
||||
fieldValues: string[],
|
||||
bounds: { earliest: number; latest: number },
|
||||
selectedJobs: ExplorerJob[],
|
||||
viewBySwimlaneFieldName: string,
|
||||
swimlaneLimit: number,
|
||||
perPage: number,
|
||||
fromPage: number,
|
||||
swimlaneContainerWidth: number,
|
||||
influencersFilterQuery?: any
|
||||
): Promise<SwimlaneData | undefined> {
|
||||
|
@ -172,7 +192,8 @@ export class ExplorerService {
|
|||
searchBounds.min.valueOf(),
|
||||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit
|
||||
perPage,
|
||||
fromPage
|
||||
);
|
||||
} else {
|
||||
response = await this.mlResultsService.getInfluencerValueMaxScoreByTime(
|
||||
|
@ -183,6 +204,8 @@ export class ExplorerService {
|
|||
searchBounds.max.valueOf(),
|
||||
interval,
|
||||
swimlaneLimit,
|
||||
perPage,
|
||||
fromPage,
|
||||
influencersFilterQuery
|
||||
);
|
||||
}
|
||||
|
@ -193,6 +216,7 @@ export class ExplorerService {
|
|||
|
||||
const viewBySwimlaneData = this.processViewByResults(
|
||||
response.results,
|
||||
response.cardinality,
|
||||
fieldValues,
|
||||
bounds,
|
||||
viewBySwimlaneFieldName,
|
||||
|
@ -204,6 +228,55 @@ export class ExplorerService {
|
|||
return viewBySwimlaneData;
|
||||
}
|
||||
|
||||
public async loadViewByTopFieldValuesForSelectedTime(
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
selectedJobs: ExplorerJob[],
|
||||
viewBySwimlaneFieldName: string,
|
||||
swimlaneLimit: number,
|
||||
perPage: number,
|
||||
fromPage: number,
|
||||
swimlaneContainerWidth: number
|
||||
) {
|
||||
const selectedJobIds = selectedJobs.map((d) => d.id);
|
||||
|
||||
// Find the top field values for the selected time, and then load the 'view by'
|
||||
// swimlane over the full time range for those specific field values.
|
||||
if (viewBySwimlaneFieldName !== VIEW_BY_JOB_LABEL) {
|
||||
const resp = await this.mlResultsService.getTopInfluencers(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
swimlaneLimit,
|
||||
perPage,
|
||||
fromPage
|
||||
);
|
||||
if (resp.influencers[viewBySwimlaneFieldName] === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const topFieldValues: any[] = [];
|
||||
const topInfluencers = resp.influencers[viewBySwimlaneFieldName];
|
||||
if (Array.isArray(topInfluencers)) {
|
||||
topInfluencers.forEach((influencerData) => {
|
||||
if (influencerData.maxAnomalyScore > 0) {
|
||||
topFieldValues.push(influencerData.influencerFieldValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
return topFieldValues;
|
||||
} else {
|
||||
const resp = await this.mlResultsService.getScoresByBucket(
|
||||
selectedJobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
this.getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth).asSeconds() + 's',
|
||||
swimlaneLimit
|
||||
);
|
||||
return Object.keys(resp.results);
|
||||
}
|
||||
}
|
||||
|
||||
private getTimeBounds(): TimeRangeBounds {
|
||||
return this._customTimeRange !== undefined
|
||||
? this.timeFilter.calculateBounds(this._customTimeRange)
|
||||
|
@ -245,6 +318,7 @@ export class ExplorerService {
|
|||
|
||||
private processViewByResults(
|
||||
scoresByInfluencerAndTime: Record<string, { [timeMs: number]: number }>,
|
||||
cardinality: number,
|
||||
sortedLaneValues: string[],
|
||||
bounds: any,
|
||||
viewBySwimlaneFieldName: string,
|
||||
|
@ -254,7 +328,7 @@ export class ExplorerService {
|
|||
// Sorts the lanes according to the supplied array of lane
|
||||
// values in the order in which they should be displayed,
|
||||
// or pass an empty array to sort lanes according to max score over all time.
|
||||
const dataset: OverallSwimlaneData = {
|
||||
const dataset: ViewBySwimLaneData = {
|
||||
fieldName: viewBySwimlaneFieldName,
|
||||
points: [],
|
||||
laneLabels: [],
|
||||
|
@ -262,6 +336,7 @@ export class ExplorerService {
|
|||
// Set the earliest and latest to be the same as the overall swim lane.
|
||||
earliest: bounds.earliest,
|
||||
latest: bounds.latest,
|
||||
cardinality,
|
||||
};
|
||||
|
||||
const maxScoreByLaneLabel: Record<string, number> = {};
|
|
@ -37,7 +37,7 @@ describe('DashboardService', () => {
|
|||
// assert
|
||||
expect(mockSavedObjectClient.find).toHaveBeenCalledWith({
|
||||
type: 'dashboard',
|
||||
perPage: 10,
|
||||
perPage: 1000,
|
||||
search: `test*`,
|
||||
searchFields: ['title^3', 'description'],
|
||||
});
|
||||
|
|
|
@ -34,7 +34,7 @@ export function dashboardServiceProvider(
|
|||
async fetchDashboards(query?: string) {
|
||||
return await savedObjectClient.find<SavedObjectDashboard>({
|
||||
type: 'dashboard',
|
||||
perPage: 10,
|
||||
perPage: 1000,
|
||||
search: query ? `${query}*` : '',
|
||||
searchFields: ['title^3', 'description'],
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@ import { annotations } from './annotations';
|
|||
import { dataFrameAnalytics } from './data_frame_analytics';
|
||||
import { filters } from './filters';
|
||||
import { resultsApiProvider } from './results';
|
||||
import { jobs } from './jobs';
|
||||
import { jobsApiProvider } from './jobs';
|
||||
import { fileDatavisualizer } from './datavisualizer';
|
||||
import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info';
|
||||
|
||||
|
@ -726,7 +726,7 @@ export function mlApiServicesProvider(httpService: HttpService) {
|
|||
dataFrameAnalytics,
|
||||
filters,
|
||||
results: resultsApiProvider(httpService),
|
||||
jobs,
|
||||
jobs: jobsApiProvider(httpService),
|
||||
fileDatavisualizer,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { http } from '../http_service';
|
||||
import { HttpService } from '../http_service';
|
||||
|
||||
import { basePath } from './index';
|
||||
import { Dictionary } from '../../../../common/types/common';
|
||||
|
@ -24,10 +24,10 @@ import {
|
|||
import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job';
|
||||
import { Category } from '../../../../common/types/categories';
|
||||
|
||||
export const jobs = {
|
||||
export const jobsApiProvider = (httpService: HttpService) => ({
|
||||
jobsSummary(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return http<MlSummaryJobs>({
|
||||
return httpService.http<MlSummaryJobs>({
|
||||
path: `${basePath()}/jobs/jobs_summary`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -36,7 +36,10 @@ export const jobs = {
|
|||
|
||||
jobsWithTimerange(dateFormatTz: string) {
|
||||
const body = JSON.stringify({ dateFormatTz });
|
||||
return http<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary<MlJobWithTimeRange> }>({
|
||||
return httpService.http<{
|
||||
jobs: MlJobWithTimeRange[];
|
||||
jobsMap: Dictionary<MlJobWithTimeRange>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/jobs_with_time_range`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -45,7 +48,7 @@ export const jobs = {
|
|||
|
||||
jobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return http<CombinedJobWithStats[]>({
|
||||
return httpService.http<CombinedJobWithStats[]>({
|
||||
path: `${basePath()}/jobs/jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -53,7 +56,7 @@ export const jobs = {
|
|||
},
|
||||
|
||||
groups() {
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/groups`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
@ -61,7 +64,7 @@ export const jobs = {
|
|||
|
||||
updateGroups(updatedJobs: string[]) {
|
||||
const body = JSON.stringify({ jobs: updatedJobs });
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/update_groups`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -75,7 +78,7 @@ export const jobs = {
|
|||
end,
|
||||
});
|
||||
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/force_start_datafeeds`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -84,7 +87,7 @@ export const jobs = {
|
|||
|
||||
stopDatafeeds(datafeedIds: string[]) {
|
||||
const body = JSON.stringify({ datafeedIds });
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/stop_datafeeds`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -93,7 +96,7 @@ export const jobs = {
|
|||
|
||||
deleteJobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/delete_jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -102,7 +105,7 @@ export const jobs = {
|
|||
|
||||
closeJobs(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/close_jobs`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -111,7 +114,7 @@ export const jobs = {
|
|||
|
||||
forceStopAndCloseJob(jobId: string) {
|
||||
const body = JSON.stringify({ jobId });
|
||||
return http<{ success: boolean }>({
|
||||
return httpService.http<{ success: boolean }>({
|
||||
path: `${basePath()}/jobs/force_stop_and_close_job`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -121,7 +124,7 @@ export const jobs = {
|
|||
jobAuditMessages(jobId: string, from?: number) {
|
||||
const jobIdString = jobId !== undefined ? `/${jobId}` : '';
|
||||
const query = from !== undefined ? { from } : {};
|
||||
return http<JobMessage[]>({
|
||||
return httpService.http<JobMessage[]>({
|
||||
path: `${basePath()}/job_audit_messages/messages${jobIdString}`,
|
||||
method: 'GET',
|
||||
query,
|
||||
|
@ -129,7 +132,7 @@ export const jobs = {
|
|||
},
|
||||
|
||||
deletingJobTasks() {
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/deleting_jobs_tasks`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
@ -137,7 +140,7 @@ export const jobs = {
|
|||
|
||||
jobsExist(jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/jobs_exist`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -146,7 +149,7 @@ export const jobs = {
|
|||
|
||||
newJobCaps(indexPatternTitle: string, isRollup: boolean = false) {
|
||||
const query = isRollup === true ? { rollup: true } : {};
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_caps/${indexPatternTitle}`,
|
||||
method: 'GET',
|
||||
query,
|
||||
|
@ -175,7 +178,7 @@ export const jobs = {
|
|||
splitFieldName,
|
||||
splitFieldValue,
|
||||
});
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_line_chart`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -202,7 +205,7 @@ export const jobs = {
|
|||
aggFieldNamePairs,
|
||||
splitFieldName,
|
||||
});
|
||||
return http<any>({
|
||||
return httpService.http<any>({
|
||||
path: `${basePath()}/jobs/new_job_population_chart`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -210,7 +213,7 @@ export const jobs = {
|
|||
},
|
||||
|
||||
getAllJobAndGroupIds() {
|
||||
return http<ExistingJobsAndGroups>({
|
||||
return httpService.http<ExistingJobsAndGroups>({
|
||||
path: `${basePath()}/jobs/all_jobs_and_group_ids`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
@ -222,7 +225,7 @@ export const jobs = {
|
|||
start,
|
||||
end,
|
||||
});
|
||||
return http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({
|
||||
return httpService.http<{ progress: number; isRunning: boolean; isJobClosed: boolean }>({
|
||||
path: `${basePath()}/jobs/look_back_progress`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -249,7 +252,7 @@ export const jobs = {
|
|||
end,
|
||||
analyzer,
|
||||
});
|
||||
return http<{
|
||||
return httpService.http<{
|
||||
examples: CategoryFieldExample[];
|
||||
sampleSize: number;
|
||||
overallValidStatus: CATEGORY_EXAMPLES_VALIDATION_STATUS;
|
||||
|
@ -263,7 +266,10 @@ export const jobs = {
|
|||
|
||||
topCategories(jobId: string, count: number) {
|
||||
const body = JSON.stringify({ jobId, count });
|
||||
return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
|
||||
return httpService.http<{
|
||||
total: number;
|
||||
categories: Array<{ count?: number; category: Category }>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/top_categories`,
|
||||
method: 'POST',
|
||||
body,
|
||||
|
@ -278,10 +284,13 @@ export const jobs = {
|
|||
calendarEvents?: Array<{ start: number; end: number; description: string }>
|
||||
) {
|
||||
const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents });
|
||||
return http<{ total: number; categories: Array<{ count?: number; category: Category }> }>({
|
||||
return httpService.http<{
|
||||
total: number;
|
||||
categories: Array<{ count?: number; category: Category }>;
|
||||
}>({
|
||||
path: `${basePath()}/jobs/revert_model_snapshot`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -14,9 +14,19 @@ export function resultsServiceProvider(
|
|||
earliestMs: number,
|
||||
latestMs: number,
|
||||
interval: string | number,
|
||||
maxResults: number
|
||||
perPage?: number,
|
||||
fromPage?: number
|
||||
): Promise<any>;
|
||||
getTopInfluencers(
|
||||
selectedJobIds: string[],
|
||||
earliestMs: number,
|
||||
latestMs: number,
|
||||
maxFieldValues: number,
|
||||
perPage?: number,
|
||||
fromPage?: number,
|
||||
influencers?: any[],
|
||||
influencersFilterQuery?: any
|
||||
): Promise<any>;
|
||||
getTopInfluencers(): Promise<any>;
|
||||
getTopInfluencerValues(): Promise<any>;
|
||||
getOverallBucketScores(
|
||||
jobIds: any,
|
||||
|
@ -33,6 +43,8 @@ export function resultsServiceProvider(
|
|||
latestMs: number,
|
||||
interval: string,
|
||||
maxResults: number,
|
||||
perPage: number,
|
||||
fromPage: number,
|
||||
influencersFilterQuery: any
|
||||
): Promise<any>;
|
||||
getRecordInfluencers(): Promise<any>;
|
||||
|
|
|
@ -9,6 +9,10 @@ import _ from 'lodash';
|
|||
import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils';
|
||||
import { escapeForElasticsearchQuery } from '../../util/string_utils';
|
||||
import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns';
|
||||
import {
|
||||
ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
} from '../../explorer/explorer_constants';
|
||||
|
||||
/**
|
||||
* Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards.
|
||||
|
@ -24,7 +28,7 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
// Pass an empty array or ['*'] to search over all job IDs.
|
||||
// Returned response contains a results property, with a key for job
|
||||
// which has results for the specified time range.
|
||||
getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) {
|
||||
getScoresByBucket(jobIds, earliestMs, latestMs, interval, perPage = 10, fromPage = 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const obj = {
|
||||
success: true,
|
||||
|
@ -88,7 +92,7 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
jobId: {
|
||||
terms: {
|
||||
field: 'job_id',
|
||||
size: maxResults !== undefined ? maxResults : 5,
|
||||
size: jobIds?.length ?? 1,
|
||||
order: {
|
||||
anomalyScore: 'desc',
|
||||
},
|
||||
|
@ -99,6 +103,12 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
field: 'anomaly_score',
|
||||
},
|
||||
},
|
||||
bucketTruncate: {
|
||||
bucket_sort: {
|
||||
from: (fromPage - 1) * perPage,
|
||||
size: perPage === 0 ? 1 : perPage,
|
||||
},
|
||||
},
|
||||
byTime: {
|
||||
date_histogram: {
|
||||
field: 'timestamp',
|
||||
|
@ -158,7 +168,9 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
jobIds,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
maxFieldValues = 10,
|
||||
maxFieldValues = ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
perPage = 10,
|
||||
fromPage = 1,
|
||||
influencers = [],
|
||||
influencersFilterQuery
|
||||
) {
|
||||
|
@ -272,6 +284,12 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
},
|
||||
},
|
||||
aggs: {
|
||||
bucketTruncate: {
|
||||
bucket_sort: {
|
||||
from: (fromPage - 1) * perPage,
|
||||
size: perPage,
|
||||
},
|
||||
},
|
||||
maxAnomalyScore: {
|
||||
max: {
|
||||
field: 'influencer_score',
|
||||
|
@ -472,7 +490,9 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
earliestMs,
|
||||
latestMs,
|
||||
interval,
|
||||
maxResults,
|
||||
maxResults = ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
perPage = SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
fromPage = 1,
|
||||
influencersFilterQuery
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -565,10 +585,15 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
},
|
||||
},
|
||||
aggs: {
|
||||
influencerValuesCardinality: {
|
||||
cardinality: {
|
||||
field: 'influencer_field_value',
|
||||
},
|
||||
},
|
||||
influencerFieldValues: {
|
||||
terms: {
|
||||
field: 'influencer_field_value',
|
||||
size: maxResults !== undefined ? maxResults : 10,
|
||||
size: !!maxResults ? maxResults : ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
order: {
|
||||
maxAnomalyScore: 'desc',
|
||||
},
|
||||
|
@ -579,6 +604,12 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
field: 'influencer_score',
|
||||
},
|
||||
},
|
||||
bucketTruncate: {
|
||||
bucket_sort: {
|
||||
from: (fromPage - 1) * perPage,
|
||||
size: perPage,
|
||||
},
|
||||
},
|
||||
byTime: {
|
||||
date_histogram: {
|
||||
field: 'timestamp',
|
||||
|
@ -618,6 +649,8 @@ export function resultsServiceProvider(mlApiServices) {
|
|||
obj.results[fieldValue] = fieldValues;
|
||||
});
|
||||
|
||||
obj.cardinality = resp.aggregations?.influencerValuesCardinality?.value ?? 0;
|
||||
|
||||
resolve(obj);
|
||||
})
|
||||
.catch((resp) => {
|
||||
|
|
|
@ -9,4 +9,3 @@ import { Subject } from 'rxjs';
|
|||
import { Refresh } from '../routing/use_refresh';
|
||||
|
||||
export const mlTimefilterRefresh$ = new Subject<Required<Refresh>>();
|
||||
export const mlTimefilterTimeChange$ = new Subject<Required<Refresh>>();
|
||||
|
|
|
@ -16,10 +16,10 @@ import {
|
|||
IContainer,
|
||||
} from '../../../../../../src/plugins/embeddable/public';
|
||||
import { MlStartDependencies } from '../../plugin';
|
||||
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
|
||||
import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container';
|
||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||
import { JobId } from '../../../common/types/anomaly_detection_jobs';
|
||||
import { ExplorerService } from '../../application/services/explorer_service';
|
||||
import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service';
|
||||
import {
|
||||
Filter,
|
||||
Query,
|
||||
|
@ -40,7 +40,7 @@ export interface AnomalySwimlaneEmbeddableCustomInput {
|
|||
jobIds: JobId[];
|
||||
swimlaneType: SwimlaneType;
|
||||
viewBy?: string;
|
||||
limit?: number;
|
||||
perPage?: number;
|
||||
|
||||
// Embeddable inputs which are not included in the default interface
|
||||
filters: Filter[];
|
||||
|
@ -58,12 +58,12 @@ export interface AnomalySwimlaneEmbeddableCustomOutput {
|
|||
jobIds: JobId[];
|
||||
swimlaneType: SwimlaneType;
|
||||
viewBy?: string;
|
||||
limit?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export interface AnomalySwimlaneServices {
|
||||
anomalyDetectorService: AnomalyDetectorService;
|
||||
explorerService: ExplorerService;
|
||||
anomalyTimelineService: AnomalyTimelineService;
|
||||
}
|
||||
|
||||
export type AnomalySwimlaneEmbeddableServices = [
|
||||
|
@ -101,14 +101,20 @@ export class AnomalySwimlaneEmbeddable extends Embeddable<
|
|||
super.render(node);
|
||||
this.node = node;
|
||||
|
||||
const I18nContext = this.services[0].i18n.Context;
|
||||
|
||||
ReactDOM.render(
|
||||
<ExplorerSwimlaneContainer
|
||||
id={this.input.id}
|
||||
embeddableInput={this.getInput$()}
|
||||
services={this.services}
|
||||
refresh={this.reload$.asObservable()}
|
||||
onOutputChange={(output) => this.updateOutput(output)}
|
||||
/>,
|
||||
<I18nContext>
|
||||
<EmbeddableSwimLaneContainer
|
||||
id={this.input.id}
|
||||
embeddableInput={this.getInput$()}
|
||||
services={this.services}
|
||||
refresh={this.reload$.asObservable()}
|
||||
onInputChange={(input) => {
|
||||
this.updateInput(input);
|
||||
}}
|
||||
/>
|
||||
</I18nContext>,
|
||||
node
|
||||
);
|
||||
}
|
||||
|
|
|
@ -46,6 +46,9 @@ describe('AnomalySwimlaneEmbeddableFactory', () => {
|
|||
});
|
||||
expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart));
|
||||
expect(createServices[1]).toMatchObject(pluginsStart);
|
||||
expect(Object.keys(createServices[2])).toEqual(['anomalyDetectorService', 'explorerService']);
|
||||
expect(Object.keys(createServices[2])).toEqual([
|
||||
'anomalyDetectorService',
|
||||
'anomalyTimelineService',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { MlStartDependencies } from '../../plugin';
|
||||
import { HttpService } from '../../application/services/http_service';
|
||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||
import { ExplorerService } from '../../application/services/explorer_service';
|
||||
import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service';
|
||||
import { mlResultsServiceProvider } from '../../application/services/results_service';
|
||||
import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout';
|
||||
import { mlApiServicesProvider } from '../../application/services/ml_api_service';
|
||||
|
@ -44,14 +44,10 @@ export class AnomalySwimlaneEmbeddableFactory
|
|||
}
|
||||
|
||||
public async getExplicitInput(): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
|
||||
const [{ overlays, uiSettings }, , { anomalyDetectorService }] = await this.getServices();
|
||||
const [coreStart] = await this.getServices();
|
||||
|
||||
try {
|
||||
return await resolveAnomalySwimlaneUserInput({
|
||||
anomalyDetectorService,
|
||||
overlays,
|
||||
uiSettings,
|
||||
});
|
||||
return await resolveAnomalySwimlaneUserInput(coreStart);
|
||||
} catch (e) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
@ -62,13 +58,13 @@ export class AnomalySwimlaneEmbeddableFactory
|
|||
|
||||
const httpService = new HttpService(coreStart.http);
|
||||
const anomalyDetectorService = new AnomalyDetectorService(httpService);
|
||||
const explorerService = new ExplorerService(
|
||||
const anomalyTimelineService = new AnomalyTimelineService(
|
||||
pluginsStart.data.query.timefilter.timefilter,
|
||||
coreStart.uiSettings,
|
||||
mlResultsServiceProvider(mlApiServicesProvider(httpService))
|
||||
);
|
||||
|
||||
return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }];
|
||||
return [coreStart, pluginsStart, { anomalyDetectorService, anomalyTimelineService }];
|
||||
}
|
||||
|
||||
public async create(
|
||||
|
|
|
@ -27,7 +27,7 @@ export interface AnomalySwimlaneInitializerProps {
|
|||
defaultTitle: string;
|
||||
influencers: string[];
|
||||
initialInput?: Partial<
|
||||
Pick<AnomalySwimlaneEmbeddableInput, 'jobIds' | 'swimlaneType' | 'viewBy' | 'limit'>
|
||||
Pick<AnomalySwimlaneEmbeddableInput, 'jobIds' | 'swimlaneType' | 'viewBy' | 'perPage'>
|
||||
>;
|
||||
onCreate: (swimlaneProps: {
|
||||
panelTitle: string;
|
||||
|
@ -38,11 +38,6 @@ export interface AnomalySwimlaneInitializerProps {
|
|||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const limitOptions = [5, 10, 25, 50].map((limit) => ({
|
||||
value: limit,
|
||||
text: `${limit}`,
|
||||
}));
|
||||
|
||||
export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = ({
|
||||
defaultTitle,
|
||||
influencers,
|
||||
|
@ -55,7 +50,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
|
|||
initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL
|
||||
);
|
||||
const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy);
|
||||
const [limit, setLimit] = useState(initialInput?.limit ?? 5);
|
||||
|
||||
const swimlaneTypeOptions = [
|
||||
{
|
||||
|
@ -154,19 +148,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
|
|||
onChange={(e) => setViewBySwimlaneFieldName(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={
|
||||
<FormattedMessage id="xpack.ml.explorer.limitLabel" defaultMessage="Limit" />
|
||||
}
|
||||
>
|
||||
<EuiSelect
|
||||
id="limit"
|
||||
name="limit"
|
||||
options={limitOptions}
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
</EuiForm>
|
||||
|
@ -186,7 +167,6 @@ export const AnomalySwimlaneInitializer: FC<AnomalySwimlaneInitializerProps> = (
|
|||
panelTitle,
|
||||
swimlaneType,
|
||||
viewBy: viewBySwimlaneFieldName,
|
||||
limit,
|
||||
})}
|
||||
fill
|
||||
>
|
||||
|
|
|
@ -5,10 +5,13 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { IUiSettingsClient, OverlayStart } from 'kibana/public';
|
||||
import { CoreStart } from 'kibana/public';
|
||||
import moment from 'moment';
|
||||
import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants';
|
||||
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
KibanaContextProvider,
|
||||
toMountPoint,
|
||||
} from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer';
|
||||
import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout';
|
||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||
|
@ -17,19 +20,17 @@ import {
|
|||
AnomalySwimlaneEmbeddableInput,
|
||||
getDefaultPanelTitle,
|
||||
} from './anomaly_swimlane_embeddable';
|
||||
import { getMlGlobalServices } from '../../application/app';
|
||||
import { HttpService } from '../../application/services/http_service';
|
||||
|
||||
export async function resolveAnomalySwimlaneUserInput(
|
||||
{
|
||||
overlays,
|
||||
anomalyDetectorService,
|
||||
uiSettings,
|
||||
}: {
|
||||
anomalyDetectorService: AnomalyDetectorService;
|
||||
overlays: OverlayStart;
|
||||
uiSettings: IUiSettingsClient;
|
||||
},
|
||||
coreStart: CoreStart,
|
||||
input?: AnomalySwimlaneEmbeddableInput
|
||||
): Promise<Partial<AnomalySwimlaneEmbeddableInput>> {
|
||||
const { http, uiSettings, overlays } = coreStart;
|
||||
|
||||
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const maps = {
|
||||
groupsMap: getInitialGroupsMap([]),
|
||||
|
@ -41,48 +42,50 @@ export async function resolveAnomalySwimlaneUserInput(
|
|||
|
||||
const selectedIds = input?.jobIds;
|
||||
|
||||
const flyoutSession = overlays.openFlyout(
|
||||
const flyoutSession = coreStart.overlays.openFlyout(
|
||||
toMountPoint(
|
||||
<JobSelectorFlyout
|
||||
selectedIds={selectedIds}
|
||||
withTimeRangeSelector={false}
|
||||
dateFormatTz={dateFormatTz}
|
||||
singleSelection={false}
|
||||
timeseriesOnly={true}
|
||||
onFlyoutClose={() => {
|
||||
flyoutSession.close();
|
||||
reject();
|
||||
}}
|
||||
onSelectionConfirmed={async ({ jobIds, groups }) => {
|
||||
const title = input?.title ?? getDefaultPanelTitle(jobIds);
|
||||
<KibanaContextProvider services={{ ...coreStart, mlServices: getMlGlobalServices(http) }}>
|
||||
<JobSelectorFlyout
|
||||
selectedIds={selectedIds}
|
||||
withTimeRangeSelector={false}
|
||||
dateFormatTz={dateFormatTz}
|
||||
singleSelection={false}
|
||||
timeseriesOnly={true}
|
||||
onFlyoutClose={() => {
|
||||
flyoutSession.close();
|
||||
reject();
|
||||
}}
|
||||
onSelectionConfirmed={async ({ jobIds, groups }) => {
|
||||
const title = input?.title ?? getDefaultPanelTitle(jobIds);
|
||||
|
||||
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
|
||||
const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise();
|
||||
|
||||
const influencers = anomalyDetectorService.extractInfluencers(jobs);
|
||||
influencers.push(VIEW_BY_JOB_LABEL);
|
||||
const influencers = anomalyDetectorService.extractInfluencers(jobs);
|
||||
influencers.push(VIEW_BY_JOB_LABEL);
|
||||
|
||||
await flyoutSession.close();
|
||||
await flyoutSession.close();
|
||||
|
||||
const modalSession = overlays.openModal(
|
||||
toMountPoint(
|
||||
<AnomalySwimlaneInitializer
|
||||
defaultTitle={title}
|
||||
influencers={influencers}
|
||||
initialInput={input}
|
||||
onCreate={({ panelTitle, viewBy, swimlaneType, limit }) => {
|
||||
modalSession.close();
|
||||
resolve({ jobIds, title: panelTitle, swimlaneType, viewBy, limit });
|
||||
}}
|
||||
onCancel={() => {
|
||||
modalSession.close();
|
||||
reject();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}}
|
||||
maps={maps}
|
||||
/>
|
||||
const modalSession = overlays.openModal(
|
||||
toMountPoint(
|
||||
<AnomalySwimlaneInitializer
|
||||
defaultTitle={title}
|
||||
influencers={influencers}
|
||||
initialInput={input}
|
||||
onCreate={({ panelTitle, viewBy, swimlaneType }) => {
|
||||
modalSession.close();
|
||||
resolve({ jobIds, title: panelTitle, swimlaneType, viewBy });
|
||||
}}
|
||||
onCancel={() => {
|
||||
modalSession.close();
|
||||
reject();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}}
|
||||
maps={maps}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
),
|
||||
{
|
||||
'data-test-subj': 'mlAnomalySwimlaneEmbeddable',
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ExplorerSwimlaneContainer } from './explorer_swimlane_container';
|
||||
import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import {
|
||||
|
@ -17,6 +17,7 @@ import { CoreStart } from 'kibana/public';
|
|||
import { MlStartDependencies } from '../../plugin';
|
||||
import { useSwimlaneInputResolver } from './swimlane_input_resolver';
|
||||
import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants';
|
||||
import { SwimlaneContainer } from '../../application/explorer/swimlane_container';
|
||||
|
||||
jest.mock('./swimlane_input_resolver', () => ({
|
||||
useSwimlaneInputResolver: jest.fn(() => {
|
||||
|
@ -24,12 +25,11 @@ jest.mock('./swimlane_input_resolver', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../application/explorer/explorer_swimlane', () => ({
|
||||
ExplorerSwimlane: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../application/components/chart_tooltip', () => ({
|
||||
MlTooltipComponent: jest.fn(),
|
||||
jest.mock('../../application/explorer/swimlane_container', () => ({
|
||||
SwimlaneContainer: jest.fn(() => {
|
||||
return null;
|
||||
}),
|
||||
isViewBySwimLaneData: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultOptions = { wrapper: I18nProvider };
|
||||
|
@ -38,6 +38,7 @@ describe('ExplorerSwimlaneContainer', () => {
|
|||
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
|
||||
let refresh: BehaviorSubject<any>;
|
||||
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
|
||||
const onInputChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
embeddableInput = new BehaviorSubject({
|
||||
|
@ -61,25 +62,39 @@ describe('ExplorerSwimlaneContainer', () => {
|
|||
};
|
||||
|
||||
(useSwimlaneInputResolver as jest.Mock).mockReturnValueOnce([
|
||||
mockOverallData,
|
||||
SWIMLANE_TYPE.OVERALL,
|
||||
undefined,
|
||||
mockOverallData,
|
||||
10,
|
||||
jest.fn(),
|
||||
{},
|
||||
false,
|
||||
null,
|
||||
]);
|
||||
|
||||
const { findByTestId } = render(
|
||||
<ExplorerSwimlaneContainer
|
||||
render(
|
||||
<EmbeddableSwimLaneContainer
|
||||
id={'test-swimlane-embeddable'}
|
||||
embeddableInput={
|
||||
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
|
||||
}
|
||||
services={services}
|
||||
refresh={refresh}
|
||||
onInputChange={onInputChange}
|
||||
/>,
|
||||
defaultOptions
|
||||
);
|
||||
expect(
|
||||
await findByTestId('mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
|
||||
).toBeDefined();
|
||||
|
||||
const calledWith = ((SwimlaneContainer as unknown) as jest.Mock<typeof SwimlaneContainer>).mock
|
||||
.calls[0][0];
|
||||
|
||||
expect(calledWith).toMatchObject({
|
||||
perPage: 10,
|
||||
swimlaneType: SWIMLANE_TYPE.OVERALL,
|
||||
swimlaneData: mockOverallData,
|
||||
isLoading: false,
|
||||
swimlaneLimit: undefined,
|
||||
fromPage: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('should render an error in case it could not fetch the ML swimlane data', async () => {
|
||||
|
@ -87,38 +102,25 @@ describe('ExplorerSwimlaneContainer', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
{ message: 'Something went wrong' },
|
||||
]);
|
||||
|
||||
const { findByText } = render(
|
||||
<ExplorerSwimlaneContainer
|
||||
<EmbeddableSwimLaneContainer
|
||||
id={'test-swimlane-embeddable'}
|
||||
embeddableInput={
|
||||
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
|
||||
}
|
||||
services={services}
|
||||
refresh={refresh}
|
||||
onInputChange={onInputChange}
|
||||
/>,
|
||||
defaultOptions
|
||||
);
|
||||
const errorMessage = await findByText('Something went wrong');
|
||||
expect(errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
test('should render a loading indicator during the data fetching', async () => {
|
||||
const { findByTestId } = render(
|
||||
<ExplorerSwimlaneContainer
|
||||
id={'test-swimlane-embeddable'}
|
||||
embeddableInput={
|
||||
embeddableInput.asObservable() as Observable<AnomalySwimlaneEmbeddableInput>
|
||||
}
|
||||
services={services}
|
||||
refresh={refresh}
|
||||
/>,
|
||||
defaultOptions
|
||||
);
|
||||
expect(
|
||||
await findByTestId('loading_mlMaxAnomalyScoreEmbeddable_test-swimlane-embeddable')
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -19,6 +19,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
let embeddableInput: BehaviorSubject<Partial<AnomalySwimlaneEmbeddableInput>>;
|
||||
let refresh: Subject<any>;
|
||||
let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices];
|
||||
let onInputChange: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
|
@ -41,7 +42,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
} as CoreStart,
|
||||
(null as unknown) as MlStartDependencies,
|
||||
({
|
||||
explorerService: {
|
||||
anomalyTimelineService: {
|
||||
setTimeRange: jest.fn(),
|
||||
loadOverallData: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
|
@ -69,6 +70,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
},
|
||||
} as unknown) as AnomalySwimlaneServices,
|
||||
];
|
||||
onInputChange = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
|
@ -79,9 +81,11 @@ describe('useSwimlaneInputResolver', () => {
|
|||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useSwimlaneInputResolver(
|
||||
embeddableInput as Observable<AnomalySwimlaneEmbeddableInput>,
|
||||
onInputChange,
|
||||
refresh,
|
||||
services,
|
||||
1000
|
||||
1000,
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
|
@ -94,7 +98,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
});
|
||||
|
||||
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1);
|
||||
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(1);
|
||||
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
embeddableInput.next({
|
||||
|
@ -109,7 +113,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
});
|
||||
|
||||
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
|
||||
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(2);
|
||||
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
embeddableInput.next({
|
||||
|
@ -124,7 +128,7 @@ describe('useSwimlaneInputResolver', () => {
|
|||
});
|
||||
|
||||
expect(services[2].anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2);
|
||||
expect(services[2].explorerService.loadOverallData).toHaveBeenCalledTimes(3);
|
||||
expect(services[2].anomalyTimelineService.loadOverallData).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -16,23 +16,31 @@ import {
|
|||
skipWhile,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
import { CoreStart } from 'kibana/public';
|
||||
import { TimeBuckets } from '../../application/util/time_buckets';
|
||||
import {
|
||||
AnomalySwimlaneEmbeddableInput,
|
||||
AnomalySwimlaneEmbeddableOutput,
|
||||
AnomalySwimlaneServices,
|
||||
} from './anomaly_swimlane_embeddable';
|
||||
import { MlStartDependencies } from '../../plugin';
|
||||
import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants';
|
||||
import {
|
||||
ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
SWIMLANE_TYPE,
|
||||
SwimlaneType,
|
||||
} from '../../application/explorer/explorer_constants';
|
||||
import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters';
|
||||
import { Query } from '../../../../../../src/plugins/data/common/query';
|
||||
import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public';
|
||||
import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils';
|
||||
import { parseInterval } from '../../../common/util/parse_interval';
|
||||
import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service';
|
||||
import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container';
|
||||
import { ViewMode } from '../../../../../../src/plugins/embeddable/public';
|
||||
|
||||
const RESIZE_IGNORED_DIFF_PX = 20;
|
||||
const FETCH_RESULTS_DEBOUNCE_MS = 500;
|
||||
|
||||
function getJobsObservable(
|
||||
|
@ -48,17 +56,31 @@ function getJobsObservable(
|
|||
|
||||
export function useSwimlaneInputResolver(
|
||||
embeddableInput: Observable<AnomalySwimlaneEmbeddableInput>,
|
||||
onInputChange: (output: Partial<AnomalySwimlaneEmbeddableOutput>) => void,
|
||||
refresh: Observable<any>,
|
||||
services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices],
|
||||
chartWidth: number
|
||||
): [string | undefined, OverallSwimlaneData | undefined, TimeBuckets, Error | null | undefined] {
|
||||
const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services;
|
||||
chartWidth: number,
|
||||
fromPage: number
|
||||
): [
|
||||
string | undefined,
|
||||
OverallSwimlaneData | undefined,
|
||||
number,
|
||||
(perPage: number) => void,
|
||||
TimeBuckets,
|
||||
boolean,
|
||||
Error | null | undefined
|
||||
] {
|
||||
const [{ uiSettings }, , { anomalyTimelineService, anomalyDetectorService }] = services;
|
||||
|
||||
const [swimlaneData, setSwimlaneData] = useState<OverallSwimlaneData>();
|
||||
const [swimlaneType, setSwimlaneType] = useState<SwimlaneType>();
|
||||
const [error, setError] = useState<Error | null>();
|
||||
const [perPage, setPerPage] = useState<number | undefined>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const chartWidth$ = useMemo(() => new Subject<number>(), []);
|
||||
const fromPage$ = useMemo(() => new Subject<number>(), []);
|
||||
const perPage$ = useMemo(() => new Subject<number>(), []);
|
||||
|
||||
const timeBuckets = useMemo(() => {
|
||||
return new TimeBuckets({
|
||||
|
@ -73,28 +95,32 @@ export function useSwimlaneInputResolver(
|
|||
const subscription = combineLatest([
|
||||
getJobsObservable(embeddableInput, anomalyDetectorService),
|
||||
embeddableInput,
|
||||
chartWidth$.pipe(
|
||||
skipWhile((v) => !v),
|
||||
distinctUntilChanged((prev, curr) => {
|
||||
// emit only if the width has been changed significantly
|
||||
return Math.abs(curr - prev) < RESIZE_IGNORED_DIFF_PX;
|
||||
})
|
||||
chartWidth$.pipe(skipWhile((v) => !v)),
|
||||
fromPage$,
|
||||
perPage$.pipe(
|
||||
startWith(undefined),
|
||||
// no need to emit when the initial value has been set
|
||||
distinctUntilChanged(
|
||||
(prev, curr) => prev === undefined && curr === SWIM_LANE_DEFAULT_PAGE_SIZE
|
||||
)
|
||||
),
|
||||
refresh.pipe(startWith(null)),
|
||||
])
|
||||
.pipe(
|
||||
tap(setIsLoading.bind(null, true)),
|
||||
debounceTime(FETCH_RESULTS_DEBOUNCE_MS),
|
||||
switchMap(([jobs, input, swimlaneContainerWidth]) => {
|
||||
switchMap(([jobs, input, swimlaneContainerWidth, fromPageInput, perPageFromState]) => {
|
||||
const {
|
||||
viewBy,
|
||||
swimlaneType: swimlaneTypeInput,
|
||||
limit,
|
||||
perPage: perPageInput,
|
||||
timeRange,
|
||||
filters,
|
||||
query,
|
||||
viewMode,
|
||||
} = input;
|
||||
|
||||
explorerService.setTimeRange(timeRange);
|
||||
anomalyTimelineService.setTimeRange(timeRange);
|
||||
|
||||
if (!swimlaneType) {
|
||||
setSwimlaneType(swimlaneTypeInput);
|
||||
|
@ -118,18 +144,34 @@ export function useSwimlaneInputResolver(
|
|||
return of(undefined);
|
||||
}
|
||||
|
||||
return from(explorerService.loadOverallData(explorerJobs, swimlaneContainerWidth)).pipe(
|
||||
return from(
|
||||
anomalyTimelineService.loadOverallData(explorerJobs, swimlaneContainerWidth)
|
||||
).pipe(
|
||||
switchMap((overallSwimlaneData) => {
|
||||
const { earliest, latest } = overallSwimlaneData;
|
||||
|
||||
if (overallSwimlaneData && swimlaneTypeInput === SWIMLANE_TYPE.VIEW_BY) {
|
||||
if (perPageFromState === undefined) {
|
||||
// set initial pagination from the input or default one
|
||||
setPerPage(perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
if (viewMode === ViewMode.EDIT && perPageFromState !== perPageInput) {
|
||||
// store per page value when the dashboard is in the edit mode
|
||||
onInputChange({ perPage: perPageFromState });
|
||||
}
|
||||
|
||||
return from(
|
||||
explorerService.loadViewBySwimlane(
|
||||
anomalyTimelineService.loadViewBySwimlane(
|
||||
[],
|
||||
{ earliest, latest },
|
||||
explorerJobs,
|
||||
viewBy!,
|
||||
limit!,
|
||||
isViewBySwimLaneData(swimlaneData)
|
||||
? swimlaneData.cardinality
|
||||
: ANOMALY_SWIM_LANE_HARD_LIMIT,
|
||||
perPageFromState ?? perPageInput ?? SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
fromPageInput,
|
||||
swimlaneContainerWidth,
|
||||
appliedFilters
|
||||
)
|
||||
|
@ -156,6 +198,7 @@ export function useSwimlaneInputResolver(
|
|||
if (data !== undefined) {
|
||||
setError(null);
|
||||
setSwimlaneData(data);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -164,11 +207,28 @@ export function useSwimlaneInputResolver(
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fromPage$.next(fromPage);
|
||||
}, [fromPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perPage === undefined) return;
|
||||
perPage$.next(perPage);
|
||||
}, [perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
chartWidth$.next(chartWidth);
|
||||
}, [chartWidth]);
|
||||
|
||||
return [swimlaneType, swimlaneData, timeBuckets, error];
|
||||
return [
|
||||
swimlaneType,
|
||||
swimlaneData,
|
||||
perPage ?? SWIM_LANE_DEFAULT_PAGE_SIZE,
|
||||
setPerPage,
|
||||
timeBuckets,
|
||||
isLoading,
|
||||
error,
|
||||
];
|
||||
}
|
||||
|
||||
export function processFilters(filters: Filter[], query: Query) {
|
||||
|
|
|
@ -14,8 +14,6 @@ import {
|
|||
AnomalySwimlaneEmbeddableOutput,
|
||||
} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
|
||||
import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout';
|
||||
import { HttpService } from '../application/services/http_service';
|
||||
import { AnomalyDetectorService } from '../application/services/anomaly_detector_service';
|
||||
|
||||
export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction';
|
||||
|
||||
|
@ -39,18 +37,10 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt
|
|||
throw new Error('Not possible to execute an action without the embeddable context');
|
||||
}
|
||||
|
||||
const [{ overlays, uiSettings, http }] = await getStartServices();
|
||||
const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http));
|
||||
const [coreStart] = await getStartServices();
|
||||
|
||||
try {
|
||||
const result = await resolveAnomalySwimlaneUserInput(
|
||||
{
|
||||
anomalyDetectorService,
|
||||
overlays,
|
||||
uiSettings,
|
||||
},
|
||||
embeddable.getInput()
|
||||
);
|
||||
const result = await resolveAnomalySwimlaneUserInput(coreStart, embeddable.getInput());
|
||||
embeddable.updateInput(result);
|
||||
} catch (e) {
|
||||
return Promise.reject();
|
||||
|
|
|
@ -9790,8 +9790,6 @@
|
|||
"xpack.ml.explorer.jobIdLabel": "ジョブ ID",
|
||||
"xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(すべての影響因子のジョブスコア)",
|
||||
"xpack.ml.explorer.kueryBar.filterPlaceholder": "影響因子フィールドでフィルタリング… ({queryExample})",
|
||||
"xpack.ml.explorer.limitLabel": "制限",
|
||||
"xpack.ml.explorer.loadingLabel": "読み込み中",
|
||||
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "選択されたジョブに影響因子が構成されていないため、トップインフルエンスリストは非表示になっています。",
|
||||
"xpack.ml.explorer.noInfluencersFoundTitle": "{viewBySwimlaneFieldName}影響因子が見つかりません",
|
||||
"xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "指定されたフィルターの{viewBySwimlaneFieldName} 影響因子が見つかりません",
|
||||
|
|
|
@ -9794,8 +9794,6 @@
|
|||
"xpack.ml.explorer.jobIdLabel": "作业 ID",
|
||||
"xpack.ml.explorer.jobScoreAcrossAllInfluencersLabel": "(所有影响因素的作业分数)",
|
||||
"xpack.ml.explorer.kueryBar.filterPlaceholder": "按影响因素字段筛选……({queryExample})",
|
||||
"xpack.ml.explorer.limitLabel": "限制",
|
||||
"xpack.ml.explorer.loadingLabel": "正在加载",
|
||||
"xpack.ml.explorer.noConfiguredInfluencersTooltip": "“顶级影响因素”列表被隐藏,因为没有为所选作业配置影响因素。",
|
||||
"xpack.ml.explorer.noInfluencersFoundTitle": "未找到任何 {viewBySwimlaneFieldName} 影响因素",
|
||||
"xpack.ml.explorer.noInfluencersFoundTitleFilterMessage": "对于指定筛选找不到任何 {viewBySwimlaneFieldName} 影响因素",
|
||||
|
|
|
@ -76,7 +76,7 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid
|
|||
async addAndEditSwimlaneInDashboard(dashboardTitle: string) {
|
||||
await this.filterWithSearchString(dashboardTitle);
|
||||
await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll');
|
||||
await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll');
|
||||
await testSubjects.clickWhenNotDisabled('mlDashboardSelectionTable > checkboxSelectAll');
|
||||
expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be(
|
||||
true
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue