[ML] Fix Anomaly charts time and query not being added to dashboard and case (#140147)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen (Quinn) 2022-09-23 05:09:03 -05:00 committed by GitHub
parent c5debde7b7
commit 5dcec78d38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 299 additions and 132 deletions

View file

@ -109,9 +109,10 @@ export class ElasticChartService extends FtrService {
*/
public async getChartDebugData(
dataTestSubj?: string,
match: number = 0
match: number = 0,
timeout: number | undefined = undefined
): Promise<DebugState | null> {
const chart = await this.getChart(dataTestSubj, undefined, match);
const chart = await this.getChart(dataTestSubj, timeout, match);
try {
const visContainer = await chart.findByCssSelector('.echChartStatus');

View file

@ -8,7 +8,6 @@
import { cloneDeep } from 'lodash';
import moment from 'moment';
import rison, { RisonValue } from 'rison-node';
import { escapeKuery } from '@kbn/es-query';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { APP_ID as MAPS_APP_ID } from '@kbn/maps-plugin/common';
import {
@ -32,7 +31,7 @@ import {
} from '../../../../common/util/date_utils';
import { parseInterval } from '../../../../common/util/parse_interval';
import { ml } from '../../services/ml_api_service';
import { replaceStringTokens } from '../../util/string_utils';
import { escapeKueryForFieldValuePair, replaceStringTokens } from '../../util/string_utils';
import { getUrlForRecord, openCustomUrlWindow } from '../../util/custom_url_utils';
import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator';
import { SEARCH_QUERY_LANGUAGE } from '../../../../common/constants/search';
@ -101,7 +100,7 @@ export const LinksMenuUI = (props: LinksMenuProps) => {
? {
query: {
language: SEARCH_QUERY_LANGUAGE.KUERY,
query: `${escapeKuery(anomaly.entityName)}:${escapeKuery(anomaly.entityValue)}`,
query: escapeKueryForFieldValuePair(anomaly.entityName, anomaly.entityValue),
},
}
: {}),
@ -147,7 +146,7 @@ export const LinksMenuUI = (props: LinksMenuProps) => {
? {
query: {
language: SEARCH_QUERY_LANGUAGE.KUERY,
query: `${escapeKuery(anomaly.entityName)}:${escapeKuery(anomaly.entityValue)}${
query: `${escapeKueryForFieldValuePair(anomaly.entityName, anomaly.entityValue)}${
influencersQueryString !== '' ? ` and (${influencersQueryString})` : ''
}`,
},

View file

@ -13,15 +13,27 @@ import {
EuiFlexItem,
EuiPopover,
EuiPopoverTitle,
formatDate,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import useObservable from 'react-use/lib/useObservable';
import type { Query, TimeRange } from '@kbn/es-query';
import { isDefined } from '../../../common/types/guards';
import { useAnomalyExplorerContext } from './anomaly_explorer_context';
import { escapeKueryForFieldValuePair } from '../util/string_utils';
import { SEARCH_QUERY_LANGUAGE } from '../../../common/constants/search';
import { useCasesModal } from '../contexts/kibana/use_cases_modal';
import { DEFAULT_MAX_SERIES_TO_PLOT } from '../services/anomaly_explorer_charts_service';
import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../embeddables';
import { useTimeRangeUpdates } from '../contexts/kibana/use_timefilter';
import { useMlKibana } from '../contexts/kibana';
import type { AppStateSelectedCells, ExplorerJob } from './explorer_utils';
import {
AppStateSelectedCells,
ExplorerJob,
getSelectionInfluencers,
getSelectionTimeRange,
} from './explorer_utils';
import { TimeRangeBounds } from '../util/time_buckets';
import { AddAnomalyChartsToDashboardControl } from './dashboard_controls/add_anomaly_charts_to_dashboard_controls';
@ -48,7 +60,6 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
const globalTimeRange = useTimeRangeUpdates(true);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false);
const closePopoverOnAction = useCallback(
(actionCallback: Function) => {
setIsMenuOpen(false);
@ -62,6 +73,36 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
const canEditDashboards = capabilities.dashboard?.createNew ?? false;
const casesPrivileges = cases?.helpers.canUseCases();
const { anomalyExplorerCommonStateService, chartsStateService } = useAnomalyExplorerContext();
const { queryString } = useObservable(
anomalyExplorerCommonStateService.getFilterSettings$(),
anomalyExplorerCommonStateService.getFilterSettings()
);
const chartsData = useObservable(
chartsStateService.getChartsData$(),
chartsStateService.getChartsData()
);
const timeRangeToPlot: TimeRange = useMemo(() => {
if (chartsData.seriesToPlot.length > 0) {
return {
from: formatDate(chartsData.seriesToPlot[0].plotEarliest, 'MMM D, YYYY @ HH:mm:ss.SSS'),
to: formatDate(chartsData.seriesToPlot[0].plotLatest, 'MMM D, YYYY @ HH:mm:ss.SSS'),
} as TimeRange;
}
if (!!selectedCells && interval !== undefined && bounds !== undefined) {
const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, bounds);
return {
from: formatDate(earliestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
to: formatDate(latestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
mode: 'absolute',
};
}
return globalTimeRange;
}, [chartsData.seriesToPlot, globalTimeRange, selectedCells, bounds, interval]);
const menuItems = useMemo(() => {
const items = [];
if (canEditDashboards) {
@ -80,6 +121,17 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
}
if (!!casesPrivileges?.create || !!casesPrivileges?.update) {
const selectionInfluencers = getSelectionInfluencers(
selectedCells,
selectedCells?.viewByFieldName!
);
const queryFromSelectedCells = Array.isArray(selectionInfluencers)
? selectionInfluencers
.map((s) => escapeKueryForFieldValuePair(s.fieldName, s.fieldValue))
.join(' or ')
: '';
items.push(
<EuiContextMenuItem
key="attachToCase"
@ -87,8 +139,16 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
null,
openCasesModal.bind(null, {
jobIds: selectedJobs?.map((v) => v.id),
timeRange: globalTimeRange,
timeRange: timeRangeToPlot,
maxSeriesToPlot: DEFAULT_MAX_SERIES_TO_PLOT,
...((isDefined(queryString) && queryString !== '') || queryFromSelectedCells !== ''
? {
query: {
query: queryString === '' ? queryFromSelectedCells : queryString,
language: SEARCH_QUERY_LANGUAGE.KUERY,
} as Query,
}
: {}),
})
)}
data-test-subj="mlAnomalyAttachChartsToCasesButton"
@ -99,7 +159,15 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
}
return items;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canEditDashboards, globalTimeRange, closePopoverOnAction, selectedJobs]);
}, [
canEditDashboards,
globalTimeRange,
closePopoverOnAction,
selectedJobs,
selectedCells,
queryString,
timeRangeToPlot,
]);
const jobIds = selectedJobs.map(({ id }) => id);
@ -140,9 +208,6 @@ export const AnomalyContextMenu: FC<AnomalyContextMenuProps> = ({
onClose={async () => {
setIsAddDashboardActive(false);
}}
selectedCells={selectedCells}
bounds={bounds}
interval={interval}
jobIds={jobIds}
/>
) : null}

View file

@ -26,6 +26,8 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import useDebounce from 'react-use/lib/useDebounce';
import useObservable from 'react-use/lib/useObservable';
import type { Query } from '@kbn/es-query';
import { SEARCH_QUERY_LANGUAGE } from '../../../common/constants/search';
import { useCasesModal } from '../contexts/kibana/use_cases_modal';
import { useTimeRangeUpdates } from '../contexts/kibana/use_timefilter';
import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE } from '../..';
@ -94,7 +96,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
const { overallAnnotations } = explorerState;
const { filterActive } = useObservable(
const { filterActive, queryString } = useObservable(
anomalyExplorerCommonStateService.getFilterSettings$(),
anomalyExplorerCommonStateService.getFilterSettings()
);
@ -171,9 +173,17 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
...(swimLaneType === SWIMLANE_TYPE.VIEW_BY ? { viewBy: viewBySwimlaneFieldName } : {}),
jobIds: selectedJobs?.map((v) => v.id),
timeRange: globalTimeRange,
...(isDefined(queryString) && queryString !== ''
? {
query: {
query: queryString,
language: SEARCH_QUERY_LANGUAGE.KUERY,
} as Query,
}
: {}),
});
},
[openCasesModalCallback, selectedJobs, globalTimeRange, viewBySwimlaneFieldName]
[openCasesModalCallback, selectedJobs, globalTimeRange, viewBySwimlaneFieldName, queryString]
);
const annotations = useMemo(() => overallAnnotations.annotationsData, [overallAnnotations]);
@ -513,6 +523,7 @@ export const AnomalyTimeline: FC<AnomalyTimelineProps> = React.memo(
}}
jobIds={selectedJobs.map(({ id }) => id)}
viewBy={viewBySwimlaneFieldName!}
queryString={queryString}
/>
)}
</>

View file

@ -53,6 +53,8 @@ interface SwimLanePagination {
* Service for managing anomaly timeline state.
*/
export class AnomalyTimelineStateService extends StateService {
// TODO: Add services for getSelectionInfluencers, getSelectionJobIds, & getSelectionTimeRange
// to consolidate usage
private readonly _explorerURLStateCallback: (
update: AnomalyExplorerSwimLaneUrlState,
replaceState?: boolean

View file

@ -6,32 +6,33 @@
*/
import React, { FC, useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiFieldNumber, EuiFormRow, formatDate, htmlIdGenerator } from '@elastic/eui';
import { TimeRange } from '@kbn/data-plugin/common/query';
import { EuiFieldNumber, EuiFormRow, htmlIdGenerator } from '@elastic/eui';
import type { Query } from '@kbn/es-query';
import useObservable from 'react-use/lib/useObservable';
import { getSelectionInfluencers } from '../explorer_utils';
import { isDefined } from '../../../../common/types/guards';
import { useAnomalyExplorerContext } from '../anomaly_explorer_context';
import { escapeKueryForFieldValuePair } from '../../util/string_utils';
import { SEARCH_QUERY_LANGUAGE } from '../../../../common/constants/search';
import { useDashboardTable } from './use_dashboards_table';
import { AddToDashboardControl } from './add_to_dashboard_controls';
import { useAddToDashboardActions } from './use_add_to_dashboard_actions';
import { AppStateSelectedCells, getSelectionTimeRange } from '../explorer_utils';
import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../services/anomaly_explorer_charts_service';
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '../../../embeddables';
import { getDefaultExplorerChartsPanelTitle } from '../../../embeddables/anomaly_charts/anomaly_charts_embeddable';
import { TimeRangeBounds } from '../../util/time_buckets';
import { useTableSeverity } from '../../components/controls/select_severity';
import { MAX_ANOMALY_CHARTS_ALLOWED } from '../../../embeddables/anomaly_charts/anomaly_charts_initializer';
function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) {
return {
id: htmlIdGenerator()(),
title: getDefaultExplorerChartsPanelTitle(jobIds),
title: getDefaultExplorerChartsPanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''),
};
}
export interface AddToDashboardControlProps {
jobIds: string[];
selectedCells?: AppStateSelectedCells | null;
bounds?: TimeRangeBounds;
interval?: number;
onClose: (callback?: () => Promise<void>) => void;
}
@ -41,34 +42,55 @@ export interface AddToDashboardControlProps {
export const AddAnomalyChartsToDashboardControl: FC<AddToDashboardControlProps> = ({
onClose,
jobIds,
selectedCells,
bounds,
interval,
}) => {
const [severity] = useTableSeverity();
const [maxSeriesToPlot, setMaxSeriesToPlot] = useState(DEFAULT_MAX_SERIES_TO_PLOT);
const { anomalyExplorerCommonStateService, anomalyTimelineStateService } =
useAnomalyExplorerContext();
const { queryString } = useObservable(
anomalyExplorerCommonStateService.getFilterSettings$(),
anomalyExplorerCommonStateService.getFilterSettings()
);
const selectedCells = useObservable(
anomalyTimelineStateService.getSelectedCells$(),
anomalyTimelineStateService.getSelectedCells()
);
const getEmbeddableInput = useCallback(() => {
let timeRange: TimeRange | undefined;
if (!!selectedCells && interval !== undefined && bounds !== undefined) {
const { earliestMs, latestMs } = getSelectionTimeRange(selectedCells, bounds);
timeRange = {
from: formatDate(earliestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
to: formatDate(latestMs, 'MMM D, YYYY @ HH:mm:ss.SSS'),
mode: 'absolute',
};
}
// Respect the query and the influencers selected
// If no query or filter set, filter out to the lanes the selected cells
// And if no selected cells, show everything
const config = getDefaultEmbeddablePanelConfig(jobIds);
const selectionInfluencers = getSelectionInfluencers(
selectedCells,
selectedCells?.viewByFieldName!
);
const influencers = selectionInfluencers ?? [];
const config = getDefaultEmbeddablePanelConfig(jobIds, queryString);
const queryFromSelectedCells = influencers
.map((s) => escapeKueryForFieldValuePair(s.fieldName, s.fieldValue))
.join(' or ');
// When adding anomaly charts to Dashboard, we want to respect the Dashboard's time range
// so we are not passing the time range here
return {
...config,
jobIds,
maxSeriesToPlot: maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT,
severityThreshold: severity.val,
...(timeRange ?? {}),
...((isDefined(queryString) && queryString !== '') ||
(queryFromSelectedCells !== undefined && queryFromSelectedCells !== '')
? {
query: {
query: queryString === '' ? queryFromSelectedCells : queryString,
language: SEARCH_QUERY_LANGUAGE.KUERY,
} as Query,
}
: {}),
};
}, [selectedCells, interval, bounds, jobIds, maxSeriesToPlot, severity]);
}, [jobIds, maxSeriesToPlot, severity, queryString, selectedCells]);
const { dashboardItems, isLoading, search } = useDashboardTable();
const { addToDashboardAndEditCallback } = useAddToDashboardActions(

View file

@ -16,6 +16,8 @@ import {
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { DashboardSavedObject } from '@kbn/dashboard-plugin/public';
import type { Query } from '@kbn/es-query';
import { SEARCH_QUERY_LANGUAGE } from '../../../../common/constants/search';
import { getDefaultSwimlanePanelTitle } from '../../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable';
import { SWIMLANE_TYPE, SwimlaneType } from '../explorer_constants';
import { JobId } from '../../../../common/types/anomaly_detection_jobs';
@ -33,9 +35,9 @@ export interface DashboardItem {
export type EuiTableProps = EuiInMemoryTableProps<DashboardItem>;
function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) {
function getDefaultEmbeddablePanelConfig(jobIds: JobId[], queryString?: string) {
return {
title: getDefaultSwimlanePanelTitle(jobIds),
title: getDefaultSwimlanePanelTitle(jobIds).concat(queryString ? `- ${queryString}` : ''),
id: htmlIdGenerator()(),
};
}
@ -44,6 +46,7 @@ interface AddToDashboardControlProps {
jobIds: JobId[];
viewBy: string;
onClose: (callback?: () => Promise<void>) => void;
queryString?: string;
}
/**
@ -53,19 +56,23 @@ export const AddSwimlaneToDashboardControl: FC<AddToDashboardControlProps> = ({
onClose,
jobIds,
viewBy,
queryString,
}) => {
const { dashboardItems, isLoading, search } = useDashboardTable();
const [selectedSwimlane, setSelectedSwimlane] = useState<SwimlaneType>(SWIMLANE_TYPE.OVERALL);
const getEmbeddableInput = useCallback(() => {
const config = getDefaultEmbeddablePanelConfig(jobIds);
const config = getDefaultEmbeddablePanelConfig(jobIds, queryString);
return {
...config,
jobIds,
swimlaneType: selectedSwimlane,
...(selectedSwimlane === SWIMLANE_TYPE.VIEW_BY ? { viewBy } : {}),
...(queryString !== undefined
? { query: { query: queryString, language: SEARCH_QUERY_LANGUAGE.KUERY } as Query }
: {}),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSwimlane]);

View file

@ -7,7 +7,6 @@
import './_index.scss';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { escapeKuery } from '@kbn/es-query';
import {
EuiButtonEmpty,
@ -46,6 +45,7 @@ import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map';
import { useActiveCursor } from '@kbn/charts-plugin/public';
import { Chart, Settings } from '@elastic/charts';
import useObservable from 'react-use/lib/useObservable';
import { escapeKueryForFieldValuePair } from '../../util/string_utils';
const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', {
defaultMessage:
@ -67,7 +67,7 @@ const openInMapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.openInM
export function getEntitiesQuery(series) {
const queryString = series.entityFields
?.map(({ fieldName, fieldValue }) => `${escapeKuery(fieldName)}:${escapeKuery(fieldValue)}`)
?.map(({ fieldName, fieldValue }) => escapeKueryForFieldValuePair(fieldName, fieldValue))
.join(' or ');
const query = {
language: SEARCH_QUERY_LANGUAGE.KUERY,

View file

@ -11,6 +11,8 @@
import d3 from 'd3';
import he from 'he';
import { escapeKuery } from '@kbn/es-query';
import { isDefined } from '../../../common/types/guards';
import { CustomUrlAnomalyRecordDoc } from '../../../common/types/custom_urls';
import { Detector } from '../../../common/types/anomaly_detection_jobs';
@ -129,6 +131,14 @@ export function escapeForElasticsearchQuery(str: string): string {
return String(str).replace(/[-[\]{}()+!<>=?:\/\\^"~*&|\s]/g, '\\$&');
}
export function escapeKueryForFieldValuePair(
name: string,
value: string | number | boolean | undefined
): string {
if (!isDefined(name) || !isDefined(value)) return '';
return `${escapeKuery(name)}:${escapeKuery(value.toString())}`;
}
export function calculateTextWidth(txt: string | number, isNumber: boolean) {
txt = isNumber && typeof txt === 'number' ? d3.format(',')(txt) : txt;

View file

@ -29,34 +29,43 @@ export const initComponent = memoize(
const inputProps =
persistableStateAttachmentState as unknown as AnomalyChartsEmbeddableInput;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
];
if (typeof inputProps.query?.query === 'string' && inputProps.query?.query !== '') {
listItems.push({
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.queryLabel"
defaultMessage="Query"
/>
),
description: inputProps.query?.query,
});
}
return (
<>
<EuiDescriptionList
compressed
type={'inline'}
listItems={[
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalyCharts.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
]}
/>
<EuiDescriptionList compressed type={'inline'} listItems={listItems} />
<EmbeddableComponent {...inputProps} />
</>
);

View file

@ -28,47 +28,57 @@ export const initComponent = memoize(
const inputProps =
persistableStateAttachmentState as unknown as AnomalySwimlaneEmbeddableInput;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
...(inputProps.viewBy
? [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.viewByLabel"
defaultMessage="View by"
/>
),
description: inputProps.viewBy,
},
]
: []),
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
];
if (typeof inputProps.query?.query === 'string' && inputProps.query?.query !== '') {
listItems.push({
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.queryLabel"
defaultMessage="Query"
/>
),
description: inputProps.query?.query,
});
}
return (
<>
<EuiDescriptionList
compressed
type={'inline'}
listItems={[
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.jobIdsLabel"
defaultMessage="Job IDs"
/>
),
description: inputProps.jobIds.join(', '),
},
...(inputProps.viewBy
? [
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.viewByLabel"
defaultMessage="View by"
/>
),
description: inputProps.viewBy,
},
]
: []),
{
title: (
<FormattedMessage
id="xpack.ml.cases.anomalySwimLane.description.timeRangeLabel"
defaultMessage="Time range"
/>
),
description: `${dataFormatter.convert(
inputProps.timeRange.from
)} - ${dataFormatter.convert(inputProps.timeRange.to)}`,
},
]}
/>
<EuiDescriptionList compressed type={'inline'} listItems={listItems} />
<EmbeddableComponent {...inputProps} />
</>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useCallback, useState, useMemo, useEffect } from 'react';
import React, { FC, useCallback, useState, useMemo, useEffect, useRef } from 'react';
import { EuiCallOut, EuiLoadingChart, EuiResizeObserver, EuiText } from '@elastic/eui';
import { Observable } from 'rxjs';
import { FormattedMessage } from '@kbn/i18n-react';
@ -117,16 +117,30 @@ export const EmbeddableAnomalyChartsContainer: FC<EmbeddableAnomalyChartsContain
severity.val,
{ onRenderComplete, onError, onLoading }
);
// Holds the container height for previously fetched data
const containerHeightRef = useRef<number>();
// eslint-disable-next-line react-hooks/exhaustive-deps
const resizeHandler = useCallback(
throttle((e: { width: number; height: number }) => {
// Keep previous container height so it doesn't change the page layout
if (!isExplorerLoading) {
containerHeightRef.current = e.height;
}
if (Math.abs(chartWidth - e.width) > 20) {
setChartWidth(e.width);
}
}, RESIZE_THROTTLE_TIME_MS),
[chartWidth]
[!isExplorerLoading, chartWidth]
);
const containerHeight = useMemo(() => {
// Persists container height during loading to prevent page from jumping
return isExplorerLoading ? containerHeightRef.current : undefined;
}, [isExplorerLoading]);
if (error) {
return (
<EuiCallOut
@ -173,6 +187,7 @@ export const EmbeddableAnomalyChartsContainer: FC<EmbeddableAnomalyChartsContain
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px',
height: containerHeight,
}}
data-test-subj={`mlExplorerEmbeddable_${embeddableContext.id}`}
ref={resizeRef}

View file

@ -13,12 +13,15 @@ import {
Query,
toElasticsearchQuery,
} from '@kbn/es-query';
import { getDefaultQuery } from '@kbn/data-plugin/public';
export function processFilters(
filters: Filter[],
query: Query,
optionalFilters?: Filter[],
optionalQuery?: Query,
controlledBy?: string
): estypes.QueryDslQueryContainer {
const filters = optionalFilters ?? [];
const query = optionalQuery ?? getDefaultQuery();
const inputQuery =
query.language === 'kuery'
? toElasticsearchQuery(fromKueryExpression(query.query as string))

View file

@ -5,10 +5,12 @@
* 2.0.
*/
import { stringHash } from '@kbn/ml-string-hash';
import type { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs';
import type { AnomalySwimlaneEmbeddableInput } from '@kbn/ml-plugin/public';
import type { AnomalyChartsEmbeddableInput } from '@kbn/ml-plugin/public/embeddables';
import type {
AnomalyChartsEmbeddableInput,
AnomalySwimlaneEmbeddableInput,
} from '@kbn/ml-plugin/public/embeddables';
import { stringHash } from '@kbn/ml-string-hash';
import type { FtrProviderContext } from '../../../ftr_provider_context';
import { USER } from '../../../services/ml/security_common';
@ -480,14 +482,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const expectedAttachment = {
jobIds: [testData.jobConfig.job_id],
timeRange: {
from: '2016-02-07T00:00:00.000Z',
to: '2016-02-11T23:59:54.000Z',
},
maxSeriesToPlot: 6,
} as AnomalyChartsEmbeddableInput;
expectedAttachment.id = stringHash(JSON.stringify(expectedAttachment)).toString();
// @ts-expect-error Setting id to be undefined here
// since time range expected is of the chart plotEarliest/plotLatest, not of the global time range
// but, chart time range might vary depends on the time of the test
// we don't know the hashed string id for sure
expectedAttachment.id = undefined;
await ml.cases.assertCaseWithAnomalyChartsAttachment(
{

View file

@ -13,20 +13,31 @@ export type MlAnomalyCharts = ProvidedType<typeof AnomalyChartsProvider>;
export function AnomalyChartsProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
return {
async assertAnomalyExplorerChartsCount(
chartsContainerSubj: string,
chartsContainerSubj: string | undefined,
expectedChartsCount: number
) {
const chartsContainer = await testSubjects.find(chartsContainerSubj);
const actualChartsCount = (
await chartsContainer.findAllByClassName('ml-explorer-chart-container', 3000)
).length;
expect(actualChartsCount).to.eql(
expectedChartsCount,
`Expect ${expectedChartsCount} charts to appear, got ${actualChartsCount}`
);
await retry.tryForTime(5000, async () => {
// For anomaly charts, time range expected is of the chart plotEarliest/plotLatest
// and not of the global time range
// but since plot earliest & latest might vary depends on the current time
// we don't know the exact hashed id for sure
// so we find first available chart container if id is not provided
const chartsContainer =
chartsContainerSubj !== undefined
? await testSubjects.find(chartsContainerSubj)
: await testSubjects.find('mlExplorerChartsContainer');
const actualChartsCount = (
await chartsContainer?.findAllByClassName('ml-explorer-chart-container', 3000)
).length;
expect(actualChartsCount).to.eql(
expectedChartsCount,
`Expect ${expectedChartsCount} charts to appear, got ${actualChartsCount}`
);
});
},
};
}

View file

@ -78,7 +78,7 @@ export function MachineLearningCasesProvider(
await testSubjects.existOrFail('comment-persistableState-ml_anomaly_charts');
await mlAnomalyCharts.assertAnomalyExplorerChartsCount(
`mlExplorerEmbeddable_${attachment.id}`,
attachment.id !== undefined ? `mlExplorerEmbeddable_${attachment.id}` : undefined,
expectedChartsCount
);
},

View file

@ -74,7 +74,7 @@ export function SwimLaneProvider({ getService }: FtrProviderContext) {
return {
async getDebugState(testSubj: string): Promise<HeatmapDebugState> {
const state = await elasticChart.getChartDebugData(testSubj);
const state = await elasticChart.getChartDebugData(testSubj, 0, 5000);
if (!state) {
throw new Error('Swim lane debug state is not available');
}