From 713c51ce07f865020f692ccfef9447c2e26c11af Mon Sep 17 00:00:00 2001 From: Maxim Mordasov Date: Thu, 2 May 2024 15:23:33 +0100 Subject: [PATCH] Display human readable time ranges in AG filters (#4288) # What this PR does Display human readable time ranges in AG filters ## Which issue(s) this PR closes Closes https://github.com/grafana/oncall/issues/4272 ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --------- Co-authored-by: Michael Derynck --- engine/apps/api/tests/test_alert_group.py | 6 +- engine/apps/api/views/alert_group.py | 2 +- engine/common/api_helpers/filters.py | 2 +- .../RemoteFilters/RemoteFilters.helpers.ts | 4 - .../RemoteFilters/RemoteFilters.tsx | 18 +-- .../src/models/alertgroup/alertgroup.ts | 18 ++- .../src/models/filters/filters.helpers.ts | 14 +++ .../src/pages/incidents/Incidents.tsx | 2 +- grafana-plugin/src/utils/datetime.test.ts | 48 ++++++++ grafana-plugin/src/utils/datetime.ts | 106 +++++++++--------- 10 files changed, 140 insertions(+), 80 deletions(-) create mode 100644 grafana-plugin/src/utils/datetime.test.ts diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 136d8e7a7c..fa4417ee99 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -110,7 +110,7 @@ def test_get_filter_started_at(alert_group_internal_api_setup, make_user_auth_he url = reverse("api-internal:alertgroup-list") response = client.get( - url + "?started_at=1970-01-01T00:00:00/2099-01-01T23:59:59", + url + "?started_at=1970-01-01T00:00:00_2099-01-01T23:59:59", format="json", **make_user_auth_headers(user, token), ) @@ -126,7 +126,7 @@ def test_get_filter_resolved_at_alertgroup_empty_result(alert_group_internal_api url = reverse("api-internal:alertgroup-list") response = client.get( - url + "?resolved_at=1970-01-01T00:00:00/1970-01-01T23:59:59", + url + "?resolved_at=1970-01-01T00:00:00_1970-01-01T23:59:59", format="json", **make_user_auth_headers(user, token), ) @@ -153,7 +153,7 @@ def test_get_filter_resolved_at(alert_group_internal_api_setup, make_user_auth_h url = reverse("api-internal:alertgroup-list") response = client.get( - url + "?resolved_at=1970-01-01T00:00:00/2099-01-01T23:59:59", + url + "?resolved_at=1970-01-01T00:00:00_2099-01-01T23:59:59", format="json", **make_user_auth_headers(user, token), ) diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index cfcc8f6133..f7590152ad 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -733,7 +733,7 @@ def filters(self, request): now = timezone.now() week_ago = now - timedelta(days=7) - default_datetime_range = "{}/{}".format( + default_datetime_range = "{}_{}".format( week_ago.strftime(DateRangeFilterMixin.DATE_FORMAT), now.strftime(DateRangeFilterMixin.DATE_FORMAT), ) diff --git a/engine/common/api_helpers/filters.py b/engine/common/api_helpers/filters.py index daa3523aed..6c904618ce 100644 --- a/engine/common/api_helpers/filters.py +++ b/engine/common/api_helpers/filters.py @@ -30,7 +30,7 @@ def parse_custom_datetime_range(cls, value): if not value: return None, None - date_entries = value.split("/") + date_entries = value.split("_") if len(date_entries) != 2: raise BadRequest(detail="Invalid range value") diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index 8969bd201a..f496d0a565 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -1,5 +1,3 @@ -import { convertRelativeToAbsoluteDate } from 'utils/datetime'; - import { FilterOption } from './RemoteFilters.types'; const normalize = (value: any) => { @@ -33,8 +31,6 @@ export function parseFilters( value = [rawValue]; } value = value.map(normalize); - } else if (filterOption.type === 'daterange') { - value = convertRelativeToAbsoluteDate(value); } else if ((filterOption.type === 'boolean' && rawValue === '') || rawValue === 'true') { value = true; } else if (rawValue === 'false') { diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index dfccafda95..a7ec0582c9 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -29,6 +29,7 @@ import { SelectOption, WithStoreProps } from 'state/types'; import { withMobXProviderContext } from 'state/withStore'; import { LocationHelper } from 'utils/LocationHelper'; import { PAGE } from 'utils/consts'; +import { convertTimerangeToFilterValue, getValueForDateRangeFilterType } from 'utils/datetime'; import { allFieldsEmpty } from 'utils/utils'; import { parseFilters } from './RemoteFilters.helpers'; @@ -314,22 +315,11 @@ class _RemoteFilters extends Component { ); case 'daterange': - const dates = values[filter.name] ? values[filter.name].split('/') : undefined; - - const value = { - from: dates ? moment(dates[0] + 'Z') : undefined, - to: dates ? moment(dates[1] + 'Z') : undefined, - raw: { - from: dates ? dates[0] : '', - to: dates ? dates[1] : '', - }, - }; + const value = getValueForDateRangeFilterType(values[filter.name]); return ( { getDateRangeFilterChangeHandler = (name: FilterOption['name']) => { return (timeRange: TimeRange) => { - const value = - timeRange.from.utc().format('YYYY-MM-DDTHH:mm:ss') + '/' + timeRange.to.utc().format('YYYY-MM-DDTHH:mm:ss'); - + const value = convertTimerangeToFilterValue(timeRange); this.onFiltersValueChange(name, value); }; }; diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 7a73a4df4b..52031f386a 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -1,6 +1,7 @@ import { runInAction, makeAutoObservable } from 'mobx'; import qs from 'query-string'; +import { convertFiltersToBackendFormat } from 'models/filters/filters.helpers'; import { ActionKey } from 'models/loader/action-keys'; import { makeRequest } from 'network/network'; import { ApiSchemas } from 'network/oncall-api/api.types'; @@ -8,7 +9,7 @@ import { onCallApi } from 'network/oncall-api/http-client'; import { RootStore } from 'state/rootStore'; import { SelectOption } from 'state/types'; import { LocationHelper } from 'utils/LocationHelper'; -import { GENERIC_ERROR } from 'utils/consts'; +import { GENERIC_ERROR, PAGE } from 'utils/consts'; import { AutoLoadingState, WithGlobalNotification } from 'utils/decorators'; import { AlertGroupHelper } from './alertgroup.helpers'; @@ -53,12 +54,18 @@ export class AlertGroupStore { ); const timestamp = new Date().getTime(); this.latestFetchAlertGroupsTimestamp = timestamp; + + const incidentFilters = convertFiltersToBackendFormat( + this.incidentFilters, + this.rootStore.filtersStore.options[PAGE.Incidents] + ); + const { data: { results, next: nextRaw, previous: previousRaw, page_size }, } = await onCallApi().GET('/alertgroups/', { params: { query: { - ...this.incidentFilters, + ...incidentFilters, search, perpage: this.alertsSearchResult?.page_size, cursor: this.incidentsCursor, @@ -201,8 +208,13 @@ export class AlertGroupStore { } async fetchStats(status: IncidentStatus) { + const incidentFilters = convertFiltersToBackendFormat( + this.incidentFilters, + this.rootStore.filtersStore.options[PAGE.Incidents] + ); + const { data } = await onCallApi().GET('/alertgroups/stats/', { - params: { query: { ...this.incidentFilters, status: [status] } }, + params: { query: { ...incidentFilters, status: [status] } }, }); runInAction(() => { diff --git a/grafana-plugin/src/models/filters/filters.helpers.ts b/grafana-plugin/src/models/filters/filters.helpers.ts index 9265c276ac..ba4a03728b 100644 --- a/grafana-plugin/src/models/filters/filters.helpers.ts +++ b/grafana-plugin/src/models/filters/filters.helpers.ts @@ -1,3 +1,7 @@ +import { convertRelativeToAbsoluteDate } from 'utils/datetime'; + +import { FilterOption, FiltersValues } from './filters.types'; + export const getApiPathByPage = (page: string) => { return ( { @@ -7,3 +11,13 @@ export const getApiPathByPage = (page: string) => { }[page] || page ); }; + +export const convertFiltersToBackendFormat = (filters: FiltersValues, filterOptions: FilterOption[]) => { + const newFilters = { ...filters }; + filterOptions.forEach((filterOption) => { + if (filterOption.type === 'daterange' && newFilters[filterOption.name]) { + newFilters[filterOption.name] = convertRelativeToAbsoluteDate(filters[filterOption.name]); + } + }); + return newFilters; +}; diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 3601a19c84..605499f3b8 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -323,7 +323,7 @@ class _IncidentsPage extends React.Component diff --git a/grafana-plugin/src/utils/datetime.test.ts b/grafana-plugin/src/utils/datetime.test.ts new file mode 100644 index 0000000000..890aa5509d --- /dev/null +++ b/grafana-plugin/src/utils/datetime.test.ts @@ -0,0 +1,48 @@ +import moment from 'moment-timezone'; + +import { convertRelativeToAbsoluteDate, getValueForDateRangeFilterType } from './datetime'; + +const DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +describe('convertRelativeToAbsoluteDate', () => { + it(`should convert relative date to absolute dates pair separated by an underscore`, () => { + const result = convertRelativeToAbsoluteDate('now-24h_now'); + + const now = moment().utc(); + const nowString = now.format(DATE_FORMAT); + const dayBefore = now.subtract('1', 'day'); + const dayBeforeString = dayBefore.format(DATE_FORMAT); + + expect(result).toBe(`${dayBeforeString}_${nowString}`); + }); +}); + +describe('getValueForDateRangeFilterType', () => { + it(`should convert relative date range string to a suitable format for TimeRangeInput component`, () => { + const input = 'now-2d_now'; + const [from, to] = input.split('_'); + const result = getValueForDateRangeFilterType(input); + + const now = moment(); + const twoDaysBefore = now.subtract('2', 'day'); + + expect(result.from.diff(twoDaysBefore, 'seconds') < 1).toBe(true); + expect(result.raw.from).toBe(from); + expect(result.from.diff(twoDaysBefore, 'seconds') < 1).toBe(true); + expect(result.raw.to).toBe(to); + }); + + it(`should convert absolute date range string to a suitable format for TimeRangeInput component`, () => { + const input = '2024-03-31T23:00:00_2024-04-15T22:59:59'; + const [from, to] = input.split('_'); + const result = getValueForDateRangeFilterType(input); + + const fromMoment = moment(from + 'Z'); + const toMoment = moment(to + 'Z'); + + expect(result.from.diff(fromMoment, 'seconds') < 1).toBe(true); + expect(result.raw.from).toBe(from); + expect(result.from.diff(toMoment, 'seconds') < 1).toBe(true); + expect(result.raw.to).toBe(to); + }); +}); diff --git a/grafana-plugin/src/utils/datetime.ts b/grafana-plugin/src/utils/datetime.ts index 85ce3ba16f..bf573d03a3 100644 --- a/grafana-plugin/src/utils/datetime.ts +++ b/grafana-plugin/src/utils/datetime.ts @@ -1,68 +1,70 @@ import { TimeOption, TimeRange, rangeUtil } from '@grafana/data'; import { TimeZone } from '@grafana/schema'; +import moment from 'moment-timezone'; -// Valid mapping accepted by @grafana/ui and @grafana/data packages -export const quickOptions = [ - { from: 'now-5m', to: 'now', display: 'Last 5 minutes' }, - { from: 'now-15m', to: 'now', display: 'Last 15 minutes' }, - { from: 'now-30m', to: 'now', display: 'Last 30 minutes' }, - { from: 'now-1h', to: 'now', display: 'Last 1 hour' }, - { from: 'now-3h', to: 'now', display: 'Last 3 hours' }, - { from: 'now-6h', to: 'now', display: 'Last 6 hours' }, - { from: 'now-12h', to: 'now', display: 'Last 12 hours' }, - { from: 'now-24h', to: 'now', display: 'Last 24 hours' }, - { from: 'now-2d', to: 'now', display: 'Last 2 days' }, - { from: 'now-7d', to: 'now', display: 'Last 7 days' }, - { from: 'now-30d', to: 'now', display: 'Last 30 days' }, - { from: 'now-90d', to: 'now', display: 'Last 90 days' }, - { from: 'now-6M', to: 'now', display: 'Last 6 months' }, - { from: 'now-1y', to: 'now', display: 'Last 1 year' }, - { from: 'now-2y', to: 'now', display: 'Last 2 years' }, - { from: 'now-5y', to: 'now', display: 'Last 5 years' }, - { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' }, - { from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday' }, - { from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' }, - { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' }, - { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' }, - { from: 'now-1Q/fQ', to: 'now-1Q/fQ', display: 'Previous fiscal quarter' }, - { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' }, - { from: 'now-1y/fy', to: 'now-1y/fy', display: 'Previous fiscal year' }, - { from: 'now/d', to: 'now/d', display: 'Today' }, - { from: 'now/d', to: 'now', display: 'Today so far' }, - { from: 'now/w', to: 'now/w', display: 'This week' }, - { from: 'now/w', to: 'now', display: 'This week so far' }, - { from: 'now/M', to: 'now/M', display: 'This month' }, - { from: 'now/M', to: 'now', display: 'This month so far' }, - { from: 'now/y', to: 'now/y', display: 'This year' }, - { from: 'now/y', to: 'now', display: 'This year so far' }, - { from: 'now/fQ', to: 'now', display: 'This fiscal quarter so far' }, - { from: 'now/fQ', to: 'now/fQ', display: 'This fiscal quarter' }, - { from: 'now/fy', to: 'now', display: 'This fiscal year so far' }, - { from: 'now/fy', to: 'now/fy', display: 'This fiscal year' }, -]; +export const DATE_RANGE_DELIMITER = '_'; export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): TimeRange => { return rangeUtil.convertRawToRange({ from: option.from, to: option.to }, timeZone); }; export function convertRelativeToAbsoluteDate(dateRangeString: string) { - const [from, to] = dateRangeString?.split('/') || []; - const isValidMapping = quickOptions.find((option) => option.from === from && option.to === to); + if (!dateRangeString) { + return undefined; + } - if (isValidMapping) { - const { from: startDate, to: endDate } = mapOptionToTimeRange({ from, to } as TimeOption); + const [from, to] = dateRangeString?.split(DATE_RANGE_DELIMITER) || []; + if (rangeUtil.isRelativeTimeRange({ from, to })) { + const { from: startDate, to: endDate } = rangeUtil.convertRawToRange({ from, to }); - // Return in the format used by on call filters - return `${startDate.format('YYYY-MM-DDTHH:mm:ss')}/${endDate.format('YYYY-MM-DDTHH:mm:ss')}`; + return `${startDate.utc().format('YYYY-MM-DDTHH:mm:ss')}${DATE_RANGE_DELIMITER}${endDate + .utc() + .format('YYYY-MM-DDTHH:mm:ss')}`; } + return dateRangeString; +} - const quickOption = quickOptions.find((option) => option.display.toLowerCase() === dateRangeString.toLowerCase()); - - if (quickOption) { - const { from: startDate, to: endDate } = mapOptionToTimeRange(quickOption as TimeOption); +export const convertTimerangeToFilterValue = (timeRange: TimeRange) => { + const isRelative = rangeUtil.isRelativeTimeRange(timeRange.raw); - return `${startDate.format('YYYY-MM-DDTHH:mm:ss')}/${endDate.format('YYYY-MM-DDTHH:mm:ss')}`; + if (isRelative) { + return timeRange.raw.from + DATE_RANGE_DELIMITER + timeRange.raw.to; + } else if (timeRange.from.isValid() && timeRange.to.isValid()) { + return ( + timeRange.from.utc().format('YYYY-MM-DDTHH:mm:ss') + + DATE_RANGE_DELIMITER + + timeRange.to.utc().format('YYYY-MM-DDTHH:mm:ss') + ); } + return ''; +}; - return dateRangeString; -} +export const getValueForDateRangeFilterType = (rawInput: string) => { + let value = { from: undefined, to: undefined, raw: { from: '', to: '' } }; + if (rawInput) { + const [fromString, toString] = rawInput.split(DATE_RANGE_DELIMITER); + const isRelative = rangeUtil.isRelativeTimeRange({ from: fromString, to: toString }); + + const raw = { + from: fromString, + to: toString, + }; + + if (isRelative) { + const absolute = convertRelativeToAbsoluteDate(rawInput); + const [absoluteFrom, absoluteTo] = absolute.split(DATE_RANGE_DELIMITER); + value = { + from: moment(absoluteFrom + 'Z'), + to: moment(absoluteTo + 'Z'), + raw, + }; + } else { + value = { + from: moment(fromString + 'Z'), + to: moment(toString + 'Z'), + raw, + }; + } + } + return value; +};