diff --git a/x-pack/plugins/ml/common/constants/settings.ts b/x-pack/plugins/ml/common/constants/settings.ts index 2df2ecd22e0788..bab2aa2f2a0aed 100644 --- a/x-pack/plugins/ml/common/constants/settings.ts +++ b/x-pack/plugins/ml/common/constants/settings.ts @@ -5,3 +5,11 @@ */ export const FILE_DATA_VISUALIZER_MAX_FILE_SIZE = 'ml:fileDataVisualizerMaxFileSize'; +export const ANOMALY_DETECTION_ENABLE_TIME_RANGE = 'ml:anomalyDetection:results:enableTimeDefaults'; +export const ANOMALY_DETECTION_DEFAULT_TIME_RANGE = 'ml:anomalyDetection:results:timeDefaults'; + +export const DEFAULT_AD_RESULTS_TIME_FILTER = { + from: 'now-15m', + to: 'now', +}; +export const DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER = false; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts new file mode 100644 index 00000000000000..368e758a027c49 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts @@ -0,0 +1,37 @@ +/* + * 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 { useCallback } from 'react'; +import { useMlKibana, useUiSettings } from '../../contexts/kibana'; +import { + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, +} from '../../../../common/constants/settings'; +import { mlJobService } from '../../services/job_service'; + +export const useCreateADLinks = () => { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); + + const useUserTimeSettings = useUiSettings().get(ANOMALY_DETECTION_ENABLE_TIME_RANGE); + const userTimeSettings = useUiSettings().get(ANOMALY_DETECTION_DEFAULT_TIME_RANGE); + const createLinkWithUserDefaults = useCallback( + (location, jobList) => { + const resultsPageUrl = mlJobService.createResultsUrlForJobs( + jobList, + location, + useUserTimeSettings === true && userTimeSettings !== undefined + ? userTimeSettings + : undefined + ); + return `${basePath.get()}/app/ml${resultsPageUrl}`; + }, + [basePath] + ); + return { createLinkWithUserDefaults }; +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx index 62a74ed142ccf0..6c57b3d08180d2 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -237,7 +237,7 @@ export const JobSelectorFlyout: FC = ({ { @@ -298,7 +315,6 @@ export class Explorer extends React.Component {
- {stoppedPartitions && ( {singleMetricVisible && ( = ({ jobsList }) => { values: { jobsCount: jobsList.length, jobId: jobsList[0] && jobsList[0].id }, } ); + const { createLinkWithUserDefaults } = useCreateADLinks(); return ( = ({ jobsWithTim const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); - + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -99,6 +99,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim // `timefilter.getBounds()` to update `bounds` in this component's state. useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -236,6 +239,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim showCharts, severity: tableSeverity.val, stoppedPartitions, + invalidTimeRangeError, }} />
diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 1f122ed18a8512..817c9754159971 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -91,6 +91,7 @@ export const TimeSeriesExplorerUrlStateManager: FC(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const refresh = useRefresh(); useEffect(() => { @@ -114,6 +115,9 @@ export const TimeSeriesExplorerUrlStateManager: FC(undefined); useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -300,6 +304,7 @@ export const TimeSeriesExplorerUrlStateManager: FC ); diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index 2134d157e1baae..30b2ec044285a2 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -5,6 +5,7 @@ */ import { SearchResponse } from 'elasticsearch'; +import { TimeRange } from 'src/plugins/data/common/query/timefilter/types'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { Calendar } from '../../../common/types/calendars'; @@ -15,7 +16,7 @@ export interface ExistingJobsAndGroups { declare interface JobService { jobs: CombinedJob[]; - createResultsUrlForJobs: (jobs: any[], target: string) => string; + createResultsUrlForJobs: (jobs: any[], target: string, timeRange?: TimeRange) => string; tempJobCloningObjects: { job: any; skipTimeRangeStep: boolean; diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 704d76059f75cc..640f63617b7d4a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -21,7 +21,7 @@ import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; import { TIME_FORMAT } from '../../../common/constants/time_format'; import { parseInterval } from '../../../common/util/parse_interval'; import { toastNotificationServiceProvider } from '../services/toast_notification_service'; - +import { validateTimeRange } from '../util/date_utils'; const msgs = mlMessageBarService; let jobs = []; let datafeedIds = {}; @@ -790,8 +790,8 @@ class JobService { return groups; } - createResultsUrlForJobs(jobsList, resultsPage) { - return createResultsUrlForJobs(jobsList, resultsPage); + createResultsUrlForJobs(jobsList, resultsPage, timeRange) { + return createResultsUrlForJobs(jobsList, resultsPage, timeRange); } createResultsUrl(jobIds, from, to, resultsPage) { @@ -932,31 +932,54 @@ function createJobStats(jobsList, jobStats) { jobStats.activeNodes.value = Object.keys(mlNodes).length; } -function createResultsUrlForJobs(jobsList, resultsPage) { +function createResultsUrlForJobs(jobsList, resultsPage, userTimeRange) { let from = undefined; let to = undefined; - if (jobsList.length === 1) { - from = jobsList[0].earliestTimestampMs; - to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + let mode = 'absolute'; + const jobIds = jobsList.map((j) => j.id); + + // if the custom default time filter is set and enabled in advanced settings + // if time is either absolute date or proper datemath format + if (validateTimeRange(userTimeRange)) { + from = userTimeRange.from; + to = userTimeRange.to; + // if both pass datemath's checks but are not technically absolute dates, use 'quick' + // e.g. "now-15m" "now+1d" + const fromFieldAValidDate = moment(userTimeRange.from).isValid(); + const toFieldAValidDate = moment(userTimeRange.to).isValid(); + if (!fromFieldAValidDate && !toFieldAValidDate) { + return createResultsUrl(jobIds, from, to, resultsPage, 'quick'); + } } else { - const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); - if (jobsWithData.length > 0) { - from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); - to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + // if time range is specified but with incorrect format + // change back to the default time range but alert the user + // that the advanced setting config is invalid + if (userTimeRange) { + mode = 'invalid'; + } + + if (jobsList.length === 1) { + from = jobsList[0].earliestTimestampMs; + to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + } else { + const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); + if (jobsWithData.length > 0) { + from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); + to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + } } } const fromString = moment(from).format(TIME_FORMAT); // Defaults to 'now' if 'from' is undefined const toString = moment(to).format(TIME_FORMAT); // Defaults to 'now' if 'to' is undefined - const jobIds = jobsList.map((j) => j.id); - return createResultsUrl(jobIds, fromString, toString, resultsPage); + return createResultsUrl(jobIds, fromString, toString, resultsPage, mode); } -function createResultsUrl(jobIds, start, end, resultsPage) { +function createResultsUrl(jobIds, start, end, resultsPage, mode = 'absolute') { const idString = jobIds.map((j) => `'${j}'`).join(','); - const from = moment(start).toISOString(); - const to = moment(end).toISOString(); + let from; + let to; let path = ''; if (resultsPage !== undefined) { @@ -964,9 +987,20 @@ function createResultsUrl(jobIds, start, end, resultsPage) { path += resultsPage; } + if (mode === 'quick') { + from = start; + to = end; + } else { + from = moment(start).toISOString(); + to = moment(end).toISOString(); + } + path += `?_g=(ml:(jobIds:!(${idString}))`; path += `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${from}'`; - path += `,mode:absolute,to:'${to}'`; + path += `,to:'${to}'`; + if (mode === 'invalid') { + path += `,mode:invalid`; + } path += "))&_a=(query:(query_string:(analyze_wildcard:!t,query:'*')))"; return path; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 83a789074d353c..0e99d64cf202f8 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -83,6 +83,7 @@ import { getFocusData, } from './timeseriesexplorer_utils'; import { EMPTY_FIELD_VALUE_LABEL } from './components/entity_control/entity_control'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -833,6 +834,22 @@ export class TimeSeriesExplorer extends React.Component { } componentDidMount() { + // if timeRange used in the url is incorrect + // perhaps due to user's advanced setting using incorrect date-maths + const { invalidTimeRangeError } = this.props; + if (invalidTimeRangeError) { + const toastNotifications = getToastNotifications(); + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range for this job due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { diff --git a/x-pack/plugins/ml/public/application/util/date_utils.ts b/x-pack/plugins/ml/public/application/util/date_utils.ts index 8f3215b6cd211f..21adc0b4b9c66a 100644 --- a/x-pack/plugins/ml/public/application/util/date_utils.ts +++ b/x-pack/plugins/ml/public/application/util/date_utils.ts @@ -8,7 +8,8 @@ // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; - +import dateMath from '@elastic/datemath'; +import { TimeRange } from '../../../../../../src/plugins/data/common'; export function formatHumanReadableDate(ts: number) { return formatDate(ts, 'MMMM Do YYYY'); } @@ -20,3 +21,10 @@ export function formatHumanReadableDateTime(ts: number): string { export function formatHumanReadableDateTimeSeconds(ts: number) { return formatDate(ts, 'MMMM Do YYYY, HH:mm:ss'); } + +export function validateTimeRange(time?: TimeRange): boolean { + if (!time) return false; + const momentDateFrom = dateMath.parse(time.from); + const momentDateTo = dateMath.parse(time.to); + return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); +} diff --git a/x-pack/plugins/ml/server/lib/register_settings.ts b/x-pack/plugins/ml/server/lib/register_settings.ts index 38b1f5e3fc0834..a9ee24fbb5cea9 100644 --- a/x-pack/plugins/ml/server/lib/register_settings.ts +++ b/x-pack/plugins/ml/server/lib/register_settings.ts @@ -7,7 +7,13 @@ import { CoreSetup } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { FILE_DATA_VISUALIZER_MAX_FILE_SIZE } from '../../common/constants/settings'; +import { + FILE_DATA_VISUALIZER_MAX_FILE_SIZE, + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, + DEFAULT_AD_RESULTS_TIME_FILTER, + DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, +} from '../../common/constants/settings'; import { MAX_FILE_SIZE } from '../../common/constants/file_datavisualizer'; export function registerKibanaSettings(coreSetup: CoreSetup) { @@ -30,5 +36,40 @@ export function registerKibanaSettings(coreSetup: CoreSetup) { }), }, }, + [ANOMALY_DETECTION_ENABLE_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Enable time filter defaults for anomaly detection results', + }), + value: DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, + schema: schema.boolean(), + description: i18n.translate( + 'xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'Use the default time filter in the Single Metric Viewer and Anomaly Explorer. If not enabled, the results for the full time range of the job are displayed.', + } + ), + category: ['Machine Learning'], + }, + [ANOMALY_DETECTION_DEFAULT_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Time filter defaults for anomaly detection results', + }), + type: 'json', + value: JSON.stringify(DEFAULT_AD_RESULTS_TIME_FILTER, null, 2), + description: i18n.translate( + 'xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'The time filter selection to use when viewing anomaly detection job results.', + } + ), + schema: schema.object({ + from: schema.string(), + to: schema.string(), + }), + requiresPageReload: true, + category: ['Machine Learning'], + }, }); }