Skip to content

Commit 2852125

Browse files
authored
Lookback window frontend (#1379)
* refactor ppl alerting apis to v1 endpoints Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> * fix monitor index unit test Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> * react 18 Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> * update to 3.5 Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> * update open search version to 3.5 in workflow Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> * move lookback window to frontend Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com> --------- Signed-off-by: KashKondaka <37753523+KashKondaka@users.noreply.github.com>
1 parent d83198c commit 2852125

File tree

12 files changed

+334
-40
lines changed

12 files changed

+334
-40
lines changed

public/app.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
import React from 'react';
6-
import { createRoot } from 'react-dom/client';
6+
import ReactDOM from 'react-dom';
77
import { HashRouter as Router, Route } from 'react-router-dom';
88
import { Provider } from 'react-redux';
99

@@ -46,8 +46,7 @@ export function renderApp(coreStart, depsStart, params, defaultRoute) {
4646
// Initialize Redux store
4747
const store = getAlertingStore();
4848

49-
const root = createRoot(params.element);
50-
root.render(
49+
ReactDOM.render(
5150
<Provider store={store}>
5251
<OpenSearchDashboardsContextProvider services={{ ...coreStart, ...depsStart }}>
5352
<OpenSearchDashboardsContextProvider services={{ data: depsStart?.data }}>
@@ -76,7 +75,8 @@ export function renderApp(coreStart, depsStart, params, defaultRoute) {
7675
</DatasetProvider>
7776
</OpenSearchDashboardsContextProvider>
7877
</OpenSearchDashboardsContextProvider>
79-
</Provider>
78+
</Provider>,
79+
params.element
8080
);
81-
return () => root.unmount();
81+
return () => ReactDOM.unmountComponentAtNode(params.element);
8282
}

public/pages/CreateMonitor/containers/CreateMonitor/PplAlertingCreateMonitor.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,20 @@ import {
3333
EuiLink,
3434
} from '@elastic/eui';
3535
import CustomSteps from '../../components/CustomSteps';
36-
import { FORMIK_INITIAL_VALUES, RECOMMENDED_DURATION } from './utils/constants';
36+
import {
37+
FORMIK_INITIAL_VALUES,
38+
RECOMMENDED_DURATION,
39+
LOOKBACK_WINDOW_MAX_MINUTES,
40+
} from './utils/constants';
3741
import {
3842
getInitialValues,
3943
getPlugins,
4044
runPPLPreview,
4145
submitPPL,
4246
extractIndicesFromPPL,
4347
findCommonDateFields,
48+
addTimeFilterToQuery,
49+
computeLookBackMinutes,
4450
} from './utils/pplAlertingHelpers';
4551
import { SubmitErrorHandler } from '../../../../utils/SubmitErrorHandler';
4652
import ConfigureTriggersPpl from '../../../CreateTrigger/containers/ConfigureTriggers/ConfigureTriggersPpl';
@@ -310,8 +316,16 @@ class PplAlertingCreateMonitor extends Component {
310316
});
311317

312318
try {
319+
let queryText = values.pplQuery || '';
320+
321+
// Inject lookback window time filter into preview query
322+
const lbMinutes = computeLookBackMinutes(values);
323+
if (lbMinutes > 0 && values.timestampField) {
324+
queryText = addTimeFilterToQuery(queryText, lbMinutes, values.timestampField);
325+
}
326+
313327
const data = await runPPLPreview(httpClient, {
314-
queryText: values.pplQuery || '',
328+
queryText,
315329
dataSourceId: values.dataSourceId || landingDataSourceId,
316330
});
317331
if (data?.ok === false) {
@@ -606,7 +620,9 @@ class PplAlertingCreateMonitor extends Component {
606620

607621
const lbMinutes =
608622
lbUnit === 'minutes' ? lbAmount : lbUnit === 'hours' ? lbAmount * 60 : lbAmount * 1440;
609-
const lbError = lbAmount !== '' && lbMinutes < 1;
623+
const lbTooSmall = lbAmount !== '' && lbMinutes < 1;
624+
const lbTooLarge = lbAmount !== '' && lbMinutes > LOOKBACK_WINDOW_MAX_MINUTES;
625+
const lbError = lbTooSmall || lbTooLarge;
610626

611627
const intervalAmount = Number(values.period?.interval ?? 1);
612628
const intervalUnit = values.period?.unit || 'MINUTES';
@@ -673,7 +689,13 @@ class PplAlertingCreateMonitor extends Component {
673689
fullWidth
674690
style={{ marginLeft: '-6px', maxWidth: '720px' }}
675691
isInvalid={lbError}
676-
error={lbError ? `Must be at least 1 minute` : undefined}
692+
error={
693+
lbTooSmall
694+
? 'Must be at least 1 minute'
695+
: lbTooLarge
696+
? 'Must be at most 7 days (10,080 minutes)'
697+
: undefined
698+
}
677699
>
678700
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
679701
<EuiFlexItem>

public/pages/CreateMonitor/containers/CreateMonitor/utils/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const DEFAULT_COMPOSITE_AGG_SIZE = 50;
128128
export const MONITOR_NAME_MAX_LENGTH = 256;
129129
export const MONITOR_DESCRIPTION_MAX_LENGTH = 500;
130130
export const LOOKBACK_WINDOW_MIN_MINUTES = 1;
131+
export const LOOKBACK_WINDOW_MAX_MINUTES = 10080; // 7 days
131132

132133
export const METRIC_TOOLTIP_TEXT = 'Extracted statistics such as simple calculations of data.';
133134
export const TIME_RANGE_TOOLTIP_TEXT = 'The time frame of data the plugin should monitor.';

public/pages/CreateMonitor/containers/CreateMonitor/utils/pplAlertingHelpers.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import _ from 'lodash';
77
import queryString from 'query-string';
8-
import { FORMIK_INITIAL_VALUES } from './constants';
8+
import { FORMIK_INITIAL_VALUES, LOOKBACK_WINDOW_MAX_MINUTES } from './constants';
99
import pplAlertingMonitorToFormik from './pplAlertingMonitorToFormik';
1010
import { buildPPLMonitorFromFormik, pplToV2Schedule } from './pplFormikToMonitor';
1111
import { MONITOR_TYPE, DEFAULT_EMPTY_DATA } from '../../../../../utils/constants';
@@ -499,6 +499,17 @@ export const submitPPL = async ({
499499
const { setSubmitting, setFieldError } = formikBag;
500500
const api = makeAlertingV2Service(httpClient);
501501

502+
// Validate lookback window bounds
503+
const lbMinutes = computeLookBackMinutes(values);
504+
if (lbMinutes > LOOKBACK_WINDOW_MAX_MINUTES) {
505+
setSubmitting(false);
506+
notifications.toasts.addDanger({
507+
title: `Failed to ${edit ? 'update' : 'create'} the monitor`,
508+
text: `Look back window must be at most 7 days (${LOOKBACK_WINDOW_MAX_MINUTES} minutes) but was ${lbMinutes} minutes.`,
509+
});
510+
return;
511+
}
512+
502513
// Validate that all triggers have names
503514
const triggerDefinitions = _.get(values, 'triggerDefinitions', []);
504515
if (Array.isArray(triggerDefinitions) && triggerDefinitions.length > 0) {
@@ -601,6 +612,54 @@ export const submitPPL = async ({
601612
}
602613
};
603614

615+
//Computes the total lookback window in minutes from formik values.
616+
export const computeLookBackMinutes = (values) => {
617+
if (!values?.useLookBackWindow) return 0;
618+
const amount = Number(values.lookBackAmount);
619+
if (!Number.isFinite(amount) || amount <= 0) return 0;
620+
const unit = String(values.lookBackUnit || 'hours').toLowerCase();
621+
if (unit.startsWith('minute')) return Math.floor(amount);
622+
if (unit.startsWith('hour')) return Math.floor(amount * 60);
623+
if (unit.startsWith('day')) return Math.floor(amount * 1440);
624+
return Math.floor(amount);
625+
};
626+
627+
//Formats date 'yyyy-MM-dd HH:mm:ss' in UTC
628+
const formatPplTimestamp = (date) => {
629+
const pad = (n) => String(n).padStart(2, '0');
630+
return (
631+
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
632+
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`
633+
);
634+
};
635+
636+
//Injects a time-range filter into a PPL query based on the lookback window.
637+
export const addTimeFilterToQuery = (
638+
query,
639+
lookBackMinutes,
640+
timestampField,
641+
endTime = new Date()
642+
) => {
643+
if (!query || !timestampField || !lookBackMinutes || lookBackMinutes <= 0) return query;
644+
645+
const periodEnd = endTime;
646+
const periodStart = new Date(periodEnd.getTime() - lookBackMinutes * 60 * 1000);
647+
648+
const startTs = formatPplTimestamp(periodStart);
649+
const endTs = formatPplTimestamp(periodEnd);
650+
651+
const timeFilterClause =
652+
`| where ${timestampField} > TIMESTAMP('${startTs}') ` +
653+
`and ${timestampField} < TIMESTAMP('${endTs}')`;
654+
655+
if (query.includes('|')) {
656+
// Inject time filter before the first pipe so it runs before aggregations
657+
return query.replace('|', `${timeFilterClause} |`);
658+
}
659+
// No pipes — append at the end
660+
return `${query} ${timeFilterClause}`;
661+
};
662+
604663
/**
605664
* Formats duration in minutes to a human-readable string
606665
* - If >= 60 minutes, converts to hours (e.g., 60 min -> 1 hour, 65 min -> 1 hr 5 min)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import {
7+
addTimeFilterToQuery,
8+
computeLookBackMinutes,
9+
extractIndicesFromPPL,
10+
formatDuration,
11+
} from './pplAlertingHelpers';
12+
13+
describe('computeLookBackMinutes', () => {
14+
test('returns 0 when lookback is disabled', () => {
15+
expect(computeLookBackMinutes({ useLookBackWindow: false })).toBe(0);
16+
expect(computeLookBackMinutes({})).toBe(0);
17+
expect(computeLookBackMinutes(null)).toBe(0);
18+
});
19+
20+
test('converts minutes correctly', () => {
21+
expect(
22+
computeLookBackMinutes({
23+
useLookBackWindow: true,
24+
lookBackAmount: 30,
25+
lookBackUnit: 'minutes',
26+
})
27+
).toBe(30);
28+
});
29+
30+
test('converts hours correctly', () => {
31+
expect(
32+
computeLookBackMinutes({ useLookBackWindow: true, lookBackAmount: 2, lookBackUnit: 'hours' })
33+
).toBe(120);
34+
});
35+
36+
test('converts days correctly', () => {
37+
expect(
38+
computeLookBackMinutes({ useLookBackWindow: true, lookBackAmount: 1, lookBackUnit: 'days' })
39+
).toBe(1440);
40+
});
41+
42+
test('returns 0 for invalid amounts', () => {
43+
expect(
44+
computeLookBackMinutes({
45+
useLookBackWindow: true,
46+
lookBackAmount: -5,
47+
lookBackUnit: 'minutes',
48+
})
49+
).toBe(0);
50+
expect(
51+
computeLookBackMinutes({
52+
useLookBackWindow: true,
53+
lookBackAmount: NaN,
54+
lookBackUnit: 'hours',
55+
})
56+
).toBe(0);
57+
});
58+
});
59+
60+
describe('addTimeFilterToQuery', () => {
61+
const fixedEnd = new Date('2025-06-15T12:00:00Z');
62+
63+
test('returns original query when inputs are missing', () => {
64+
expect(addTimeFilterToQuery('', 60, '@timestamp')).toBe('');
65+
expect(addTimeFilterToQuery('source=logs', 0, '@timestamp')).toBe('source=logs');
66+
expect(addTimeFilterToQuery('source=logs', 60, '')).toBe('source=logs');
67+
});
68+
69+
test('appends time filter when query has no pipes', () => {
70+
const result = addTimeFilterToQuery('source=logs', 60, '@timestamp', fixedEnd);
71+
expect(result).toContain("| where @timestamp > TIMESTAMP('2025-06-15 11:00:00')");
72+
expect(result).toContain("and @timestamp < TIMESTAMP('2025-06-15 12:00:00')");
73+
expect(result).toMatch(/^source=logs \|/);
74+
});
75+
76+
test('injects time filter before first pipe when query has pipes', () => {
77+
const query = 'source=logs | stats count() by status';
78+
const result = addTimeFilterToQuery(query, 120, '@timestamp', fixedEnd);
79+
expect(result).toContain("| where @timestamp > TIMESTAMP('2025-06-15 10:00:00')");
80+
expect(result).toContain('| stats count() by status');
81+
// Time filter should come before the stats command
82+
const timeFilterIdx = result.indexOf('| where @timestamp');
83+
const statsIdx = result.indexOf('| stats');
84+
expect(timeFilterIdx).toBeLessThan(statsIdx);
85+
});
86+
87+
test('uses correct lookback window in minutes', () => {
88+
const result = addTimeFilterToQuery('source=idx', 30, 'event_time', fixedEnd);
89+
expect(result).toContain("event_time > TIMESTAMP('2025-06-15 11:30:00')");
90+
expect(result).toContain("event_time < TIMESTAMP('2025-06-15 12:00:00')");
91+
});
92+
93+
test('handles multi-day lookback', () => {
94+
const result = addTimeFilterToQuery('source=idx', 1440, 'ts', fixedEnd);
95+
expect(result).toContain("ts > TIMESTAMP('2025-06-14 12:00:00')");
96+
expect(result).toContain("ts < TIMESTAMP('2025-06-15 12:00:00')");
97+
});
98+
99+
test('preserves original query structure with complex piped query', () => {
100+
const query = 'source=logs | where status=500 | stats avg(latency) as avg_lat by region';
101+
const result = addTimeFilterToQuery(query, 60, '@timestamp', fixedEnd);
102+
expect(result).toContain('| where status=500');
103+
expect(result).toContain('| stats avg(latency) as avg_lat by region');
104+
// Time filter injected before the first original pipe
105+
expect(result.indexOf('| where @timestamp')).toBeLessThan(result.indexOf('| where status=500'));
106+
});
107+
});
108+
109+
describe('extractIndicesFromPPL', () => {
110+
test('extracts single index', () => {
111+
expect(extractIndicesFromPPL('source=logs | where status=500')).toEqual(['logs']);
112+
});
113+
114+
test('extracts multiple comma-separated indices', () => {
115+
expect(extractIndicesFromPPL('source=logs,metrics,events')).toEqual([
116+
'logs',
117+
'metrics',
118+
'events',
119+
]);
120+
});
121+
122+
test('extracts index with wildcard', () => {
123+
expect(extractIndicesFromPPL('source=logs-* | stats count()')).toEqual(['logs-*']);
124+
});
125+
126+
test('handles backtick-quoted indices', () => {
127+
expect(extractIndicesFromPPL('source=`my-index` | head 10')).toEqual(['my-index']);
128+
});
129+
130+
test('returns empty array for empty input', () => {
131+
expect(extractIndicesFromPPL('')).toEqual([]);
132+
expect(extractIndicesFromPPL(null)).toEqual([]);
133+
});
134+
});
135+
136+
describe('formatDuration', () => {
137+
test('formats minutes', () => {
138+
expect(formatDuration(30)).toBe('30 minutes');
139+
expect(formatDuration(1)).toBe('1 minute');
140+
});
141+
142+
test('formats hours', () => {
143+
expect(formatDuration(60)).toBe('1 hour');
144+
expect(formatDuration(120)).toBe('2 hours');
145+
expect(formatDuration(90)).toBe('1 hr 30 min');
146+
});
147+
148+
test('formats days', () => {
149+
expect(formatDuration(1440)).toBe('1 d');
150+
expect(formatDuration(2880)).toBe('2 d');
151+
});
152+
153+
test('handles zero and null', () => {
154+
expect(formatDuration(0)).toBe('0 minutes');
155+
expect(formatDuration(null)).toBe('-');
156+
});
157+
});

public/pages/CreateMonitor/containers/CreateMonitor/utils/pplAlertingMonitorToFormik.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,26 @@ export default function pplAlertingMonitorToFormik(monitorIn) {
9797
};
9898

9999
const pplQuery = monitor.query || '';
100-
const timestampField = monitor.timestamp_field || '@timestamp';
101100
const description = monitor.description || '';
102101

102+
const lookbackMeta = uiMetadata?.lookback;
103+
const lookBackMinutes =
104+
monitor.look_back_window_minutes ??
105+
monitor.look_back_window ??
106+
(lookbackMeta?.enabled ? lookbackMeta.minutes : undefined);
107+
108+
const timestampField = monitor.timestamp_field || lookbackMeta?.timestamp_field || '@timestamp';
109+
103110
let lookBackFormik = {};
104-
const lookBackMinutes = monitor.look_back_window_minutes ?? monitor.look_back_window;
105-
if (lookBackMinutes != null) {
111+
if (lookbackMeta && typeof lookbackMeta.enabled === 'boolean') {
112+
if (lookbackMeta.enabled && lookbackMeta.minutes > 0) {
113+
lookBackFormik.useLookBackWindow = true;
114+
lookBackFormik.lookBackAmount = lookbackMeta.amount || lookbackMeta.minutes;
115+
lookBackFormik.lookBackUnit = lookbackMeta.unit || 'minutes';
116+
} else {
117+
lookBackFormik.useLookBackWindow = false;
118+
}
119+
} else if (lookBackMinutes != null && lookBackMinutes > 0) {
106120
const minutes = lookBackMinutes;
107121
lookBackFormik.useLookBackWindow = true;
108122

@@ -117,8 +131,6 @@ export default function pplAlertingMonitorToFormik(monitorIn) {
117131
lookBackFormik.lookBackUnit = 'minutes';
118132
}
119133
} else {
120-
// Explicitly set useLookBackWindow to false when look_back_window_minutes is null or undefined
121-
// Don't include lookBackAmount and lookBackUnit to prevent old values from persisting
122134
lookBackFormik.useLookBackWindow = false;
123135
}
124136

@@ -138,8 +150,9 @@ export default function pplAlertingMonitorToFormik(monitorIn) {
138150
detectorId: isAD ? _.get(inputs, INPUTS_DETECTOR_ID) : undefined,
139151
adResultIndex: isAD ? _.get(inputs, '0.search.indices.0') : undefined,
140152
...(pplQuery ? { pplQuery } : {}),
141-
...(monitor.timestamp_field ? { timestampField } : {}),
153+
timestampField,
142154
...lookBackFormik,
155+
ui_metadata: uiMetadata,
143156
};
144157

145158
// When useLookBackWindow is false, explicitly clear lookBackAmount and lookBackUnit

0 commit comments

Comments
 (0)