Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Data Frame Analytics custom URLs: adds ability to set custom time range in urls #155337

Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React, { FC, useMemo, useState } from 'react';
import moment, { type Moment } from 'moment';
import {
EuiDatePicker,
EuiDatePickerRange,
EuiFlexItem,
EuiFlexGroup,
EuiFormRow,
EuiIconTip,
EuiSpacer,
EuiSwitch,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useMlKibana } from '../../../contexts/kibana';

interface CustomUrlTimeRangePickerProps {
onCustomTimeRangeChange: (customTimeRange?: { start: Moment; end: Moment }) => void;
customTimeRange?: { start: Moment; end: Moment };
}

/*
* React component for the form for adding a custom time range.
*/
export const CustomTimeRangePicker: FC<CustomUrlTimeRangePickerProps> = ({
onCustomTimeRangeChange,
customTimeRange,
}) => {
const [showCustomTimeRangeSelector, setShowCustomTimeRangeSelector] = useState<boolean>(false);
const {
services: {
data: {
query: {
timefilter: { timefilter },
},
},
},
} = useMlKibana();

const onCustomTimeRangeSwitchChange = (checked: boolean) => {
if (checked === false) {
// Clear the custom time range so it isn't persisted
onCustomTimeRangeChange(undefined);
}
setShowCustomTimeRangeSelector(checked);
};

// If the custom time range is not set, default to the timefilter settings
const currentTimeRange = useMemo(
() =>
customTimeRange ?? {
start: moment(timefilter.getAbsoluteTime().from),
end: moment(timefilter.getAbsoluteTime().to),
},
[customTimeRange, timefilter]
);

const handleStartChange = (date: moment.Moment) => {
onCustomTimeRangeChange({ ...currentTimeRange, start: date });
};
const handleEndChange = (date: moment.Moment) => {
onCustomTimeRangeChange({ ...currentTimeRange, end: date });
};

const { start, end } = currentTimeRange;

return (
<>
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize={'none'}>
<EuiFlexItem grow={false}>
<EuiIconTip
content={i18n.translate('xpack.ml.customUrlsEditor.customTimeRangeTooltip', {
defaultMessage: 'If not set, time range defaults to global settings.',
})}
position="top"
type="iInCircle"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
display="columnCompressedSwitch"
label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.customTimeRangeSwitch"
defaultMessage="Add custom time range?"
/>
}
>
<EuiSwitch
showLabel={false}
label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.addCustomTimeRangeSwitchLabel"
defaultMessage="Add custom time range switch"
/>
}
checked={showCustomTimeRangeSelector}
onChange={(e) => onCustomTimeRangeSwitchChange(e.target.checked)}
compressed
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showCustomTimeRangeSelector ? (
<>
<EuiSpacer size="s" />
<EuiFormRow
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the destination data view of the job has a timestamp field, and the target page is either Dashboard or Discover with a data view that has a timestamp field, it would be good to include an extra option here as well as the custom time range option, where the user can specify an interval. The action here would be to open the target page showing interval either side of the time of the row the action is being run on, similar to the option we have for anomaly detection job custom URLs:

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This item is part of the overall meta issue #150375 and can be done in a separate PR 👍

label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.customTimeRangeLabel"
defaultMessage="Custom time range"
/>
}
>
<EuiDatePickerRange
data-test-subj={`mlCustomUrlsDateRange`}
isInvalid={start > end}
startDateControl={
<EuiDatePicker
selected={start}
onChange={handleStartChange}
startDate={start}
endDate={end}
aria-label={i18n.translate('xpack.ml.customUrlsEditor.customTimeRangeStartDate', {
defaultMessage: 'Start date',
})}
showTimeSelect
/>
}
endDateControl={
<EuiDatePicker
selected={end}
onChange={handleEndChange}
startDate={start}
endDate={end}
aria-label={i18n.translate('xpack.ml.customUrlsEditor.customTimeRangeEndDate', {
defaultMessage: 'End date',
})}
showTimeSelect
/>
}
/>
</EuiFormRow>
</>
) : null}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* 2.0.
*/

import React, { ChangeEvent, useMemo, useState, useRef, useEffect, FC } from 'react';
import React, { ChangeEvent, useState, useRef, useEffect, FC } from 'react';
import { type Moment } from 'moment';

import {
EuiComboBox,
Expand All @@ -29,10 +30,11 @@ import { DataView } from '@kbn/data-views-plugin/public';
import { CustomUrlSettings, isValidCustomUrlSettingsTimeRange } from './utils';
import { isValidLabel } from '../../../util/custom_url_utils';
import { type DataFrameAnalyticsConfig } from '../../../../../common/types/data_frame_analytics';
import { Job, isAnomalyDetectionJob } from '../../../../../common/types/anomaly_detection_jobs';
import { type Job } from '../../../../../common/types/anomaly_detection_jobs';

import { TIME_RANGE_TYPE, TimeRangeType, URL_TYPE } from './constants';
import { UrlConfig } from '../../../../../common/types/custom_urls';
import { CustomTimeRangePicker } from './custom_time_range_picker';
import { useMlKibana } from '../../../contexts/kibana';
import { getDropDownOptions } from './get_dropdown_options';

Expand Down Expand Up @@ -66,6 +68,7 @@ interface CustomUrlEditorProps {
dashboards: Array<{ id: string; title: string }>;
dataViewListItems: DataViewListItem[];
showTimeRangeSelector?: boolean;
showCustomTimeRangeSelector: boolean;
job: Job | DataFrameAnalyticsConfig;
}

Expand All @@ -78,10 +81,12 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({
savedCustomUrls,
dashboards,
dataViewListItems,
showTimeRangeSelector,
showCustomTimeRangeSelector,
job,
}) => {
const [queryEntityFieldNames, setQueryEntityFieldNames] = useState<string[]>([]);
const isAnomalyJob = useMemo(() => isAnomalyDetectionJob(job), [job]);
const [hasTimefield, setHasTimefield] = useState<boolean>(false);

const {
services: {
Expand All @@ -101,6 +106,9 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({
} catch (e) {
dataViewToUse = undefined;
}
if (dataViewToUse && dataViewToUse.timeFieldName) {
setHasTimefield(true);
}
const dropDownOptions = await getDropDownOptions(isFirst.current, job, dataViewToUse);
setQueryEntityFieldNames(dropDownOptions);

Expand Down Expand Up @@ -132,6 +140,13 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({
});
};

const onCustomTimeRangeChange = (timeRange?: { start: Moment; end: Moment }) => {
setEditCustomUrl({
...customUrl,
customTimeRange: timeRange,
});
};

const onDashboardChange = (e: ChangeEvent<HTMLSelectElement>) => {
const kibanaSettings = customUrl.kibanaSettings;
setEditCustomUrl({
Expand Down Expand Up @@ -345,58 +360,66 @@ export const CustomUrlEditor: FC<CustomUrlEditorProps> = ({
/>
</EuiFormRow>
)}
{type === URL_TYPE.KIBANA_DASHBOARD ||
(type === URL_TYPE.KIBANA_DISCOVER && showCustomTimeRangeSelector && hasTimefield) ? (
<CustomTimeRangePicker
onCustomTimeRangeChange={onCustomTimeRangeChange}
customTimeRange={customUrl?.customTimeRange}
/>
) : null}

{(type === URL_TYPE.KIBANA_DASHBOARD || type === URL_TYPE.KIBANA_DISCOVER) && isAnomalyJob && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.timeRangeLabel"
defaultMessage="Time range"
/>
}
className="url-time-range"
display="rowCompressed"
>
<EuiSelect
options={timeRangeOptions}
value={timeRange.type}
onChange={onTimeRangeTypeChange}
data-test-subj="mlJobCustomUrlTimeRangeInput"
compressed
/>
</EuiFormRow>
</EuiFlexItem>
{timeRange.type === TIME_RANGE_TYPE.INTERVAL && (
<EuiFlexItem>
{(type === URL_TYPE.KIBANA_DASHBOARD || type === URL_TYPE.KIBANA_DISCOVER) &&
showTimeRangeSelector && (
<>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.intervalLabel"
defaultMessage="Interval"
id="xpack.ml.customUrlsEditor.timeRangeLabel"
defaultMessage="Time range"
/>
}
className="url-time-range"
error={invalidIntervalError}
isInvalid={isInvalidTimeRange}
display="rowCompressed"
>
<EuiFieldText
value={timeRange.interval}
onChange={onTimeRangeIntervalChange}
isInvalid={isInvalidTimeRange}
data-test-subj="mlJobCustomUrlTimeRangeIntervalInput"
<EuiSelect
options={timeRangeOptions}
value={timeRange.type}
onChange={onTimeRangeTypeChange}
data-test-subj="mlJobCustomUrlTimeRangeInput"
compressed
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
)}
{timeRange.type === TIME_RANGE_TYPE.INTERVAL && (
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ml.customUrlsEditor.intervalLabel"
defaultMessage="Interval"
/>
}
className="url-time-range"
error={invalidIntervalError}
isInvalid={isInvalidTimeRange}
display="rowCompressed"
>
<EuiFieldText
value={timeRange.interval}
onChange={onTimeRangeIntervalChange}
isInvalid={isInvalidTimeRange}
data-test-subj="mlJobCustomUrlTimeRangeIntervalInput"
compressed
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
</>
)}

{type === URL_TYPE.OTHER && (
<EuiFormRow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { Moment } from 'moment';
import type { SerializableRecord } from '@kbn/utility-types';
import rison from '@kbn/rison';
import url from 'url';
Expand Down Expand Up @@ -57,6 +58,7 @@ export interface CustomUrlSettings {
// Note timeRange is only editable in new URLs for Dashboard and Discover URLs,
// as for other URLs we have no way of knowing how the field will be used in the URL.
timeRange: TimeRange;
customTimeRange?: { start: Moment; end: Moment };
kibanaSettings?: {
dashboardId?: string;
queryFieldNames?: string[];
Expand Down Expand Up @@ -221,6 +223,20 @@ export function buildCustomUrlFromSettings(settings: CustomUrlSettings): Promise
}
}

function getUrlRangeFromSettings(settings: CustomUrlSettings) {
let customStart;
let customEnd;

if (settings.customTimeRange && settings.customTimeRange.start && settings.customTimeRange.end) {
customStart = settings.customTimeRange.start.toISOString();
customEnd = settings.customTimeRange.end.toISOString();
}
return {
from: customStart ?? '$earliest$',
to: customEnd ?? '$latest$',
};
}

async function buildDashboardUrlFromSettings(settings: CustomUrlSettings): Promise<UrlConfig> {
// Get the complete list of attributes for the selected dashboard (query, filters).
const { dashboardId, queryFieldNames } = settings.kibanaSettings ?? {};
Expand Down Expand Up @@ -253,11 +269,13 @@ async function buildDashboardUrlFromSettings(settings: CustomUrlSettings): Promi

const dashboard = getDashboard();

const { from, to } = getUrlRangeFromSettings(settings);

const location = await dashboard?.locator?.getLocation({
dashboardId,
timeRange: {
from: '$earliest$',
to: '$latest$',
from,
to,
mode: 'absolute',
},
filters,
Expand Down Expand Up @@ -299,10 +317,12 @@ function buildDiscoverUrlFromSettings(settings: CustomUrlSettings) {
// Add time settings to the global state URL parameter with $earliest$ and
// $latest$ tokens which get substituted for times around the time of the
// anomaly on which the URL will be run against.
const { from, to } = getUrlRangeFromSettings(settings);

const _g = rison.encode({
time: {
from: '$earliest$',
to: '$latest$',
from,
to,
mode: 'absolute',
},
});
Expand Down
Loading