Skip to content

Commit 061b930

Browse files
authored
[Synthetics] Add logical AND to monitor tags and locations filter (elastic#217985)
1 parent 7ac6488 commit 061b930

21 files changed

Lines changed: 415 additions & 78 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
export const useLogicalAndFields = ['tags', 'locations'] as const;
9+
10+
export type UseLogicalAndField = (typeof useLogicalAndFields)[number];
11+
12+
export const isLogicalAndField = (field: string): field is UseLogicalAndField => {
13+
return Object.values<string>(useLogicalAndFields).includes(field);
14+
};

x-pack/solutions/observability/plugins/synthetics/common/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export * from './capabilities';
1111
export * from './settings_defaults';
1212
export * from './ui';
1313
export * from './synthetics';
14+
export * from './filters_fields_with_logical_and';

x-pack/solutions/observability/plugins/synthetics/common/runtime_types/monitor_management/state.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
*/
77

88
import * as t from 'io-ts';
9+
import { Mixed } from 'io-ts';
10+
import { useLogicalAndFields } from '../../constants/filters_fields_with_logical_and';
911

10-
export const FetchMonitorManagementListQueryArgsCodec = t.partial({
11-
page: t.number,
12-
perPage: t.number,
13-
sortField: t.string,
14-
sortOrder: t.union([t.literal('desc'), t.literal('asc')]),
12+
const useLogicalAndFileLiteral = useLogicalAndFields.map((f) => t.literal(f)) as unknown as [
13+
Mixed,
14+
Mixed,
15+
...Mixed[]
16+
];
17+
18+
const FetchMonitorQueryArgsCommon = {
1519
query: t.string,
1620
searchFields: t.array(t.string),
1721
tags: t.array(t.string),
@@ -20,26 +24,25 @@ export const FetchMonitorManagementListQueryArgsCodec = t.partial({
2024
projects: t.array(t.string),
2125
schedules: t.array(t.string),
2226
monitorQueryIds: t.array(t.string),
23-
internal: t.boolean,
27+
sortField: t.string,
28+
sortOrder: t.union([t.literal('desc'), t.literal('asc')]),
2429
showFromAllSpaces: t.boolean,
30+
useLogicalAndFor: t.array(t.union(useLogicalAndFileLiteral)),
31+
};
32+
33+
export const FetchMonitorManagementListQueryArgsCodec = t.partial({
34+
...FetchMonitorQueryArgsCommon,
35+
page: t.number,
36+
perPage: t.number,
37+
internal: t.boolean,
2538
});
2639

2740
export type FetchMonitorManagementListQueryArgs = t.TypeOf<
2841
typeof FetchMonitorManagementListQueryArgsCodec
2942
>;
3043

3144
export const FetchMonitorOverviewQueryArgsCodec = t.partial({
32-
query: t.string,
33-
searchFields: t.array(t.string),
34-
tags: t.array(t.string),
35-
locations: t.array(t.string),
36-
projects: t.array(t.string),
37-
schedules: t.array(t.string),
38-
monitorTypes: t.array(t.string),
39-
monitorQueryIds: t.array(t.string),
40-
sortField: t.string,
41-
sortOrder: t.string,
42-
showFromAllSpaces: t.boolean,
45+
...FetchMonitorQueryArgsCommon,
4346
});
4447

4548
export type FetchMonitorOverviewQueryArgs = t.TypeOf<typeof FetchMonitorOverviewQueryArgsCodec>;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { before, expect, journey, step, after } from '@elastic/synthetics';
9+
import { RetryService } from '@kbn/ftr-common-functional-services';
10+
import { syntheticsAppPageProvider } from '../page_objects/synthetics_app';
11+
import { SyntheticsServices } from './services/synthetics_services';
12+
13+
const FIRST_TAG = 'a';
14+
const SECOND_TAG = 'b';
15+
16+
journey('FilterMonitors', async ({ page, params }) => {
17+
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl, params });
18+
const syntheticsService = new SyntheticsServices(params);
19+
const retry: RetryService = params.getService('retry');
20+
21+
before(async () => {
22+
await syntheticsService.cleanUp();
23+
});
24+
25+
after(async () => {
26+
await syntheticsService.cleanUp();
27+
});
28+
29+
step('Go to Monitors overview page', async () => {
30+
await syntheticsApp.navigateToOverview(true, 15);
31+
});
32+
33+
step('Create test monitors', async () => {
34+
const common = { type: 'http', urls: 'https://www.google.com', locations: ['us_central'] };
35+
await syntheticsService.addTestMonitor('Test Filter Monitors 1 Tag', {
36+
...common,
37+
tags: [FIRST_TAG],
38+
});
39+
await syntheticsService.addTestMonitor('Test Filter Monitors 2 Tags', {
40+
...common,
41+
tags: [FIRST_TAG, SECOND_TAG],
42+
});
43+
await page.getByTestId('syntheticsRefreshButtonButton').click();
44+
});
45+
46+
step('Filter monitors by tags: use logical AND', async () => {
47+
let requestMade = false;
48+
page.on('request', (request) => {
49+
if (
50+
request
51+
.url()
52+
.includes(`synthetics/overview_status?query=&tags=${FIRST_TAG}&tags=${SECOND_TAG}`) &&
53+
request.url().includes('useLogicalAndFor=tags') &&
54+
request.method() === 'GET'
55+
) {
56+
requestMade = true;
57+
}
58+
});
59+
60+
// Click on the Tags filter button using aria-label
61+
await page.getByLabel('expands filter group for Tags filter').click();
62+
63+
// Click on both tags and on the logical AND switch
64+
await page.getByRole('option', { name: FIRST_TAG }).click();
65+
await page.getByRole('option', { name: SECOND_TAG }).click();
66+
await page.getByTestId('tagsLogicalOperatorSwitch').click();
67+
await page.getByTestId('o11yFieldValueSelectionApplyButton').click();
68+
69+
await retry.tryForTime(5 * 1000, async () => {
70+
expect(requestMade).toBe(true);
71+
// Only one monitor should be shown because we are using logical AND
72+
await expect(page.getByText('Showing 1 Monitor')).toBeVisible();
73+
});
74+
});
75+
76+
step('Filter monitors by tags: use logical OR', async () => {
77+
let requestMade = false;
78+
page.on('request', (request) => {
79+
if (
80+
request
81+
.url()
82+
.includes(`synthetics/overview_status?query=&tags=${FIRST_TAG}&tags=${SECOND_TAG}`) &&
83+
request.method() === 'GET'
84+
) {
85+
requestMade = true;
86+
}
87+
});
88+
89+
// Click on the Tags filter button using aria-label
90+
await page.getByLabel('expands filter group for Tags filter').click();
91+
92+
// Turn off the logical AND switch
93+
await page.getByTestId('tagsLogicalOperatorSwitch').click();
94+
await page.getByTestId('o11yFieldValueSelectionApplyButton').click();
95+
96+
await retry.tryForTime(5 * 1000, async () => {
97+
expect(requestMade).toBe(true);
98+
// Two monitors should be shown because we are using logical OR
99+
await expect(page.getByText('Showing 2 Monitors')).toBeVisible();
100+
});
101+
});
102+
});

x-pack/solutions/observability/plugins/synthetics/e2e/synthetics/journeys/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ export * from './test_run_details.journey';
2727
export * from './step_details.journey';
2828
export * from './project_monitor_read_only.journey';
2929
export * from './overview_save_lens_visualization.journey';
30+
export * from './filter_monitors.journey';

x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/filter_button.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import React, { useState } from 'react';
99
import { FieldValueSelection } from '@kbn/observability-shared-plugin/public';
10+
import { isLogicalAndField } from '../../../../../../../common/constants';
1011
import {
1112
getSyntheticsFilterDisplayValues,
1213
SyntheticsMonitorFilterItem,
@@ -37,6 +38,8 @@ export const FilterButton = ({
3738
[]
3839
).map(({ label: selectedValueLabel }) => selectedValueLabel);
3940

41+
const showLogicalConditionSwitch = isLogicalAndField(field);
42+
4043
return (
4144
<FieldValueSelection
4245
selectedValue={selectedValueLabels}
@@ -48,10 +51,14 @@ export const FilterButton = ({
4851
: values
4952
}
5053
setQuery={setQuery}
51-
onChange={(selectedValues) => handleFilterChange(field, selectedValues)}
54+
onChange={(selectedValues, _, isLogicalAND) =>
55+
handleFilterChange(field, selectedValues, isLogicalAND)
56+
}
5257
allowExclusions={false}
5358
loading={loading}
5459
asFilterButton={true}
60+
showLogicalConditionSwitch={showLogicalConditionSwitch}
61+
useLogicalAND={showLogicalConditionSwitch && urlParams.useLogicalAndFor?.includes(field)}
5562
/>
5663
);
5764
};

x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/common/monitor_filters/use_filters.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { useMemo, useEffect, useCallback, useRef } from 'react';
99
import { useDispatch, useSelector } from 'react-redux';
1010

11+
import { isLogicalAndField } from '../../../../../../../common/constants';
1112
import { MonitorFiltersResult } from '../../../../../../../common/runtime_types';
1213
import {
1314
MonitorFilterState,
@@ -59,6 +60,20 @@ export function useMonitorFiltersState() {
5960
}, []);
6061

6162
const dispatch = useDispatch();
63+
const { useLogicalAndFor } = urlParams;
64+
65+
useEffect(() => {
66+
dispatch(
67+
setOverviewPageStateAction({
68+
useLogicalAndFor,
69+
})
70+
);
71+
dispatch(
72+
updateManagementPageStateAction({
73+
useLogicalAndFor,
74+
})
75+
);
76+
}, [dispatch, useLogicalAndFor]);
6277

6378
const serializeFilterValue = useCallback(
6479
(field: FilterFieldWithQuery, selectedValues: string[] | undefined) => {
@@ -92,13 +107,28 @@ export function useMonitorFiltersState() {
92107
);
93108

94109
const handleFilterChange: SyntheticsMonitorFilterChangeHandler = useCallback(
95-
(field: SyntheticsMonitorFilterField, selectedValues: string[] | undefined) => {
96-
// Update url to reflect the changed filter
97-
updateUrlParams({
110+
(
111+
field: SyntheticsMonitorFilterField,
112+
selectedValues: string[] | undefined,
113+
isLogicalAND?: boolean
114+
) => {
115+
const newUrlParams: Partial<Record<SyntheticsMonitorFilterField, string>> = {
98116
[field]: serializeFilterValue(field, selectedValues),
99-
});
117+
};
118+
119+
if (isLogicalAndField(field)) {
120+
const currentUseLogicalAndFor = urlParams.useLogicalAndFor || [];
121+
newUrlParams.useLogicalAndFor = serializeFilterValue(
122+
'useLogicalAndFor',
123+
isLogicalAND
124+
? [...currentUseLogicalAndFor, field]
125+
: currentUseLogicalAndFor.filter((item: string) => item !== field)
126+
);
127+
}
128+
// Update url to reflect the changed filter
129+
updateUrlParams(newUrlParams);
100130
},
101-
[serializeFilterValue, updateUrlParams]
131+
[serializeFilterValue, updateUrlParams, urlParams.useLogicalAndFor]
102132
);
103133

104134
const reduxState = useSelector(selectMonitorFiltersAndQueryState);

x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_filters.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,20 +100,17 @@ describe('useMonitorFilters', () => {
100100
it('should handle a combination of parameters', () => {
101101
spaceSpy.mockReturnValue({ space: { id: 'space3' } } as any);
102102
paramSpy.mockReturnValue({
103-
schedules: 'daily',
104103
projects: ['projectA'],
105104
tags: ['tagB'],
106105
locations: ['locationC'],
107106
monitorTypes: 'http',
108107
} as any);
109-
selSPy.mockReturnValue({ status: { allIds: ['id3', 'id4'] } });
110108

111109
const { result } = renderHook(() => useMonitorFilters({ forAlerts: false }), {
112110
wrapper: WrappedHelper,
113111
});
114112

115113
expect(result.current).toEqual([
116-
{ field: 'monitor.id', values: ['id3', 'id4'] },
117114
{ field: 'monitor.project.id', values: ['projectA'] },
118115
{ field: 'monitor.type', values: ['http'] },
119116
{ field: 'tags', values: ['tagB'] },

x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_filters.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,51 @@
77

88
import { UrlFilter } from '@kbn/exploratory-view-plugin/public';
99
import { useSelector } from 'react-redux';
10-
import { isEmpty } from 'lodash';
10+
import { isEmpty, uniqueId } from 'lodash';
1111
import { useGetUrlParams } from '../../../hooks/use_url_params';
1212
import { useKibanaSpace } from '../../../../../hooks/use_kibana_space';
1313
import { selectOverviewStatus } from '../../../state/overview_status';
1414

15+
const createFiltersForField = ({
16+
field,
17+
values,
18+
useLogicalAnd = false,
19+
}: {
20+
field: string;
21+
values: string | string[] | undefined;
22+
useLogicalAnd?: boolean;
23+
}): UrlFilter[] => {
24+
if (!values || !values.length) return [];
25+
26+
const valueArray = getValues(values);
27+
28+
return useLogicalAnd
29+
? valueArray.map((value) => ({ field, values: [value] }))
30+
: [{ field, values: valueArray }];
31+
};
32+
1533
export const useMonitorFilters = ({ forAlerts }: { forAlerts?: boolean }): UrlFilter[] => {
1634
const { space } = useKibanaSpace();
17-
const { locations, monitorTypes, tags, projects, schedules } = useGetUrlParams();
35+
const { locations, monitorTypes, tags, projects, schedules, useLogicalAndFor } =
36+
useGetUrlParams();
1837
const { status: overviewStatus } = useSelector(selectOverviewStatus);
1938
const allIds = overviewStatus?.allIds ?? [];
2039

40+
// since schedule isn't available in heartbeat data, in that case we rely on monitor.id
41+
// We need to rely on monitor.id also for locations, because each heartbeat data only contains one location
42+
if (!isEmpty(schedules) || (!isEmpty(locations) && useLogicalAndFor?.includes('locations'))) {
43+
// If allIds is empty we return an array with a random id just to not get any result, there's probably a better solution
44+
return [{ field: 'monitor.id', values: allIds.length ? allIds : [uniqueId()] }];
45+
}
46+
2147
return [
22-
// since schedule isn't available in heartbeat data, in that case we rely on monitor.id
23-
...(allIds?.length && !isEmpty(schedules) ? [{ field: 'monitor.id', values: allIds }] : []),
2448
...(projects?.length ? [{ field: 'monitor.project.id', values: getValues(projects) }] : []),
2549
...(monitorTypes?.length ? [{ field: 'monitor.type', values: getValues(monitorTypes) }] : []),
26-
...(tags?.length ? [{ field: 'tags', values: getValues(tags) }] : []),
50+
...createFiltersForField({
51+
useLogicalAnd: useLogicalAndFor?.includes('tags'),
52+
field: 'tags',
53+
values: tags,
54+
}),
2755
...(locations?.length ? [{ field: 'observer.geo.name', values: getValues(locations) }] : []),
2856
...(space
2957
? [{ field: forAlerts ? 'kibana.space_ids' : 'meta.space_id', values: [space.id] }]

x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,14 @@ describe('useMonitorList', () => {
4343
handleFilterChange: jest.fn(),
4444
};
4545

46-
filterState = { locations: [], monitorTypes: [], projects: [], schedules: [], tags: [] };
46+
filterState = {
47+
locations: [],
48+
monitorTypes: [],
49+
projects: [],
50+
schedules: [],
51+
tags: [],
52+
useLogicalAndFor: [],
53+
};
4754
filterStateWithQuery = { ...filterState, query: 'xyz' };
4855
});
4956

0 commit comments

Comments
 (0)