[Synthetics] Overview and Management filters (#149469)

Closes #135160
Fixes https://github.com/elastic/kibana/issues/146075

## Summary

Adds the Frequency and Project filter on Management page and all the
filters on Overview page as well.

The PR doesn't show the filters on a dialog as in the design, for the
sake of utilizing existing available component and the fact that opening
a dialog adds one additional step to reach filters. The applied filter
pills/tags (as in the design) are also not implemented as the filter
components show a highlighted number if any filter is applied. Incase
this implementation is not sufficient, the same components can be
converted to match the design easily.

<img width="1482" alt="Screenshot 2023-01-25 at 23 18 30"
src="https://user-images.githubusercontent.com/2748376/214705768-78b431a5-d0fd-4141-82f2-0e9d3af0d8ee.png">

<img width="1484" alt="Screenshot 2023-01-25 at 23 19 12"
src="https://user-images.githubusercontent.com/2748376/214705792-bf62004d-7666-408b-8ca1-4f0f7520a950.png">

---------

Co-authored-by: shahzad31 <shahzad31comp@gmail.com>
This commit is contained in:
Abdul Wahab Zahid 2023-02-01 18:34:27 +01:00 committed by GitHub
parent e8eb04420e
commit 4795910ef3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 855 additions and 388 deletions

View file

@ -16,7 +16,9 @@ export const FetchMonitorManagementListQueryArgsCodec = t.partial({
searchFields: t.array(t.string),
tags: t.array(t.string),
locations: t.array(t.string),
monitorType: t.array(t.string),
monitorTypes: t.array(t.string),
projects: t.array(t.string),
schedules: t.array(t.string),
});
export type FetchMonitorManagementListQueryArgs = t.TypeOf<
@ -28,7 +30,9 @@ export const FetchMonitorOverviewQueryArgsCodec = t.partial({
searchFields: t.array(t.string),
tags: t.array(t.string),
locations: t.array(t.string),
monitorType: t.array(t.string),
projects: t.array(t.string),
schedules: t.array(t.string),
monitorTypes: t.array(t.string),
sortField: t.string,
sortOrder: t.string,
});

View file

@ -27,6 +27,7 @@ export const OverviewStatusCodec = t.interface({
disabledCount: t.number,
upConfigs: t.record(t.string, OverviewStatusMetaDataCodec),
downConfigs: t.record(t.string, OverviewStatusMetaDataCodec),
allIds: t.array(t.string),
enabledIds: t.array(t.string),
});

View file

@ -42,6 +42,7 @@ journey(`Getting Started Page`, async ({ page, params }: { page: Page; params: a
});
step('shows validation error on submit', async () => {
await page.locator('.euiSideNavItem').locator('text=Synthetics').click();
await page.click('text=Create monitor');
expect(await page.isVisible('text=URL is required')).toBeTruthy();

View file

@ -36,7 +36,10 @@ journey(`MonitorManagementList`, async ({ page, params }) => {
await addTestMonitor(params.kibanaUrl, testMonitor1);
await addTestMonitor(params.kibanaUrl, testMonitor2);
await addTestMonitor(params.kibanaUrl, testMonitor3);
await addTestMonitor(params.kibanaUrl, testMonitor3, {
type: 'browser',
schedule: { unit: 'm', number: '5' },
});
});
after(async () => {
@ -64,17 +67,17 @@ journey(`MonitorManagementList`, async ({ page, params }) => {
});
step(
'Click [aria-label="Use up and down arrows to move focus over options. Enter to select. Escape to collapse options."] >> text=browser',
'Click [aria-label="Use up and down arrows to move focus over options. Enter to select. Escape to collapse options."] >> text="Journey / Page"',
async () => {
await page.click(
'[aria-label="Use up and down arrows to move focus over options. Enter to select. Escape to collapse options."] >> text=browser'
'[aria-label="Use up and down arrows to move focus over options. Enter to select. Escape to collapse options."] >> text="Journey / Page"'
);
await page.click('[aria-label="Apply the selected filters for Type"]');
expect(page.url()).toBe(`${pageBaseUrl}?monitorType=%5B%22browser%22%5D`);
expect(page.url()).toBe(`${pageBaseUrl}?monitorTypes=%5B%22browser%22%5D`);
await page.click('[placeholder="Search by name, url, host, tag, project or location"]');
await Promise.all([
page.waitForNavigation({
url: `${pageBaseUrl}?monitorType=%5B%22browser%22%5D&query=3`,
url: `${pageBaseUrl}?monitorTypes=%5B%22browser%22%5D&query=3`,
}),
page.fill('[placeholder="Search by name, url, host, tag, project or location"]', '3'),
]);
@ -99,4 +102,22 @@ journey(`MonitorManagementList`, async ({ page, params }) => {
await expect(statSummaryPanel.locator('text=3').count()).resolves.toEqual(1); // Configurations
await expect(statSummaryPanel.locator('text=0').count()).resolves.toEqual(1); // Disabled
});
step('Filter by Frequency', async () => {
const frequencyFilter = page.locator('.euiFilterButton__textShift', { hasText: 'Frequency' });
const fiveMinuteScheduleOption = page.getByText('Every 5 minutes').first();
await frequencyFilter.click();
await fiveMinuteScheduleOption.click();
await page.getByText('Apply').click();
// There should be only 1 monitor with schedule 5 minutes
await page.waitForSelector('text=1-1');
// Clear the filter
await frequencyFilter.click();
await fiveMinuteScheduleOption.click();
await page.getByText('Apply').click();
await page.waitForSelector('text=1-3');
});
});

View file

@ -0,0 +1,55 @@
/*
* 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, { useState } from 'react';
import { FieldValueSelection } from '@kbn/observability-plugin/public';
import {
getSyntheticsFilterDisplayValues,
SyntheticsMonitorFilterItem,
valueToLabelWithEmptyCount,
} from './filter_fields';
import { useGetUrlParams } from '../../../../hooks';
import { useMonitorFiltersState } from './use_filters';
export const FilterButton = ({
filter,
handleFilterChange,
}: {
filter: SyntheticsMonitorFilterItem;
handleFilterChange: ReturnType<typeof useMonitorFiltersState>['handleFilterChange'];
}) => {
const { label, values, field } = filter;
const [query, setQuery] = useState('');
const urlParams = useGetUrlParams();
// Transform the values to readable labels (if any) so that selected values are checked on filter dropdown
const selectedValueLabels = getSyntheticsFilterDisplayValues(
(urlParams[field] || []).map(valueToLabelWithEmptyCount),
field,
[]
).map(({ label: selectedValueLabel }) => selectedValueLabel);
return (
<FieldValueSelection
selectedValue={selectedValueLabels}
singleSelection={false}
label={label}
values={
query
? values.filter(({ label: str }) => str.toLowerCase().includes(query.toLowerCase()))
: values
}
setQuery={setQuery}
onChange={(selectedValues) => handleFilterChange(field, selectedValues)}
allowExclusions={false}
loading={false}
asFilterButton={true}
/>
);
};

View file

@ -0,0 +1,91 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { invert } from 'lodash';
import { DataStream, ServiceLocations } from '../../../../../../../common/runtime_types';
import { MonitorFilterState } from '../../../../state';
export type SyntheticsMonitorFilterField = keyof MonitorFilterState;
export interface LabelWithCountValue {
label: string;
count: number;
}
export interface SyntheticsMonitorFilterItem {
label: string;
values: LabelWithCountValue[];
field: SyntheticsMonitorFilterField;
}
export function getMonitorFilterFields(): SyntheticsMonitorFilterField[] {
return ['tags', 'locations', 'monitorTypes', 'projects', 'schedules'];
}
export type SyntheticsMonitorFilterChangeHandler = (
field: SyntheticsMonitorFilterField,
selectedValues: string[] | undefined
) => void;
export function getSyntheticsFilterDisplayValues(
values: LabelWithCountValue[],
field: SyntheticsMonitorFilterField,
locations: ServiceLocations
) {
switch (field) {
case 'monitorTypes':
return values.map(({ label, count }: { label: string; count: number }) => ({
label: monitorTypeKeyLabelMap[label as DataStream] ?? label,
count,
}));
case 'schedules':
return values.map(({ label, count }: { label: string; count: number }) => ({
label: i18n.translate('xpack.synthetics.monitorFilters.frequencyLabel', {
defaultMessage: `Every {count} minutes`,
values: { count: label },
}),
count,
}));
case 'locations':
return values.map(({ label, count }) => {
const foundLocation = locations.find(
({ id: locationId, label: locationLabel }) =>
label === locationId || label === locationLabel
);
return {
label: foundLocation?.label ?? label,
count,
};
});
default:
return values;
}
}
export function getSyntheticsFilterKeyForLabel(value: string, field: SyntheticsMonitorFilterField) {
switch (field) {
case 'monitorTypes':
return invert(monitorTypeKeyLabelMap)[value] ?? value;
case 'schedules':
return (value ?? '').replace(/\D/g, '');
default:
return value;
}
}
export const valueToLabelWithEmptyCount = (value: string): LabelWithCountValue => ({
label: value,
count: 0,
});
const monitorTypeKeyLabelMap: Record<DataStream, string> = {
[DataStream.BROWSER]: 'Journey / Page',
[DataStream.HTTP]: 'HTTP',
[DataStream.TCP]: 'TCP',
[DataStream.ICMP]: 'ICMP',
};

View file

@ -10,50 +10,64 @@ import { EuiFilterGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSelector } from 'react-redux';
import { ServiceLocations } from '../../../../../../../common/runtime_types';
import { useFilters } from './use_filters';
import { FilterButton } from './filter_button';
import { selectServiceLocationsState } from '../../../../state';
export interface FilterItem {
label: string;
values: Array<{ label: string; count: number }>;
field: 'tags' | 'status' | 'locations' | 'monitorType';
}
import {
SyntheticsMonitorFilterItem,
getSyntheticsFilterDisplayValues,
SyntheticsMonitorFilterChangeHandler,
} from './filter_fields';
import { useFilters } from './use_filters';
import { FilterButton } from './filter_button';
export const findLocationItem = (query: string, locations: ServiceLocations) => {
return locations.find(({ id, label }) => query === id || label === query);
};
export const FilterGroup = () => {
export const FilterGroup = ({
handleFilterChange,
}: {
handleFilterChange: SyntheticsMonitorFilterChangeHandler;
}) => {
const data = useFilters();
const { locations } = useSelector(selectServiceLocationsState);
const filters: FilterItem[] = [
const filters: SyntheticsMonitorFilterItem[] = [
{
label: TYPE_LABEL,
field: 'monitorType',
values: data.types,
field: 'monitorTypes',
values: getSyntheticsFilterDisplayValues(data.monitorTypes, 'monitorTypes', locations),
},
{
label: LOCATION_LABEL,
field: 'locations',
values: data.locations.map(({ label, count }) => ({
label: findLocationItem(label, locations)?.label ?? label,
count,
})),
values: getSyntheticsFilterDisplayValues(data.locations, 'locations', locations),
},
{
label: TAGS_LABEL,
field: 'tags',
values: data.tags,
values: getSyntheticsFilterDisplayValues(data.tags, 'tags', locations),
},
{
label: SCHEDULE_LABEL,
field: 'schedules',
values: getSyntheticsFilterDisplayValues(data.schedules, 'schedules', locations),
},
];
if (data.projects.length > 0) {
filters.push({
label: PROJECT_LABEL,
field: 'projects',
values: getSyntheticsFilterDisplayValues(data.projects, 'projects', locations),
});
}
return (
<EuiFilterGroup>
{filters.map((filter, index) => (
<FilterButton key={index} filter={filter} />
<FilterButton key={index} filter={filter} handleFilterChange={handleFilterChange} />
))}
</EuiFilterGroup>
);
@ -63,6 +77,10 @@ const TYPE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter.typ
defaultMessage: `Type`,
});
const PROJECT_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter.projectLabel', {
defaultMessage: `Project`,
});
const LOCATION_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter.locationLabel', {
defaultMessage: `Location`,
});
@ -70,3 +88,7 @@ const LOCATION_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter
const TAGS_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter.tagsLabel', {
defaultMessage: `Tags`,
});
const SCHEDULE_LABEL = i18n.translate('xpack.synthetics.monitorManagement.filter.frequencyLabel', {
defaultMessage: `Frequency`,
});

View file

@ -7,17 +7,23 @@
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FilterGroup } from './filter_group';
import { SearchField } from '../../common/search_field';
export function ListFilters() {
import { FilterGroup } from './filter_group';
import { SearchField } from '../search_field';
import { SyntheticsMonitorFilterChangeHandler } from './filter_fields';
export function ListFilters({
handleFilterChange,
}: {
handleFilterChange: SyntheticsMonitorFilterChangeHandler;
}) {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexGroup gutterSize="s" wrap={true}>
<EuiFlexItem grow={2}>
<SearchField />
</EuiFlexItem>
<EuiFlexItem grow={1}>
<FilterGroup />
<FilterGroup handleFilterChange={handleFilterChange} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -13,18 +13,30 @@ describe('useMonitorListFilters', () => {
it('returns expected results', () => {
const { result } = renderHook(() => useFilters(), { wrapper: WrappedHelper });
expect(result.current).toStrictEqual({ locations: [], tags: [], types: [] });
expect(result.current).toStrictEqual({
locations: [],
tags: [],
monitorTypes: [],
projects: [],
schedules: [],
});
expect(defaultCore.savedObjects.client.find).toHaveBeenCalledWith({
aggs: {
locations: {
terms: { field: 'synthetics-monitor.attributes.locations.id', size: 10000 },
},
monitorTypes: {
terms: { field: 'synthetics-monitor.attributes.type.keyword', size: 10000 },
},
projects: {
terms: { field: 'synthetics-monitor.attributes.project_id', size: 10000 },
},
schedules: {
terms: { field: 'synthetics-monitor.attributes.schedule.number', size: 10000 },
},
tags: {
terms: { field: 'synthetics-monitor.attributes.tags', size: 10000 },
},
types: {
terms: { field: 'synthetics-monitor.attributes.type.keyword', size: 10000 },
},
},
perPage: 0,
type: 'synthetics-monitor',
@ -40,18 +52,30 @@ describe('useMonitorListFilters', () => {
{ key: 'Test 2', doc_count: 2 },
],
},
tags: {
monitorTypes: {
buckets: [
{ key: 'Test 3', doc_count: 3 },
{ key: 'Test 4', doc_count: 4 },
],
},
types: {
projects: {
buckets: [
{ key: 'Test 5', doc_count: 5 },
{ key: 'Test 6', doc_count: 6 },
],
},
schedules: {
buckets: [
{ key: 'Test 7', doc_count: 7 },
{ key: 'Test 8', doc_count: 8 },
],
},
tags: {
buckets: [
{ key: 'Test 9', doc_count: 9 },
{ key: 'Test 10', doc_count: 10 },
],
},
},
});
@ -59,7 +83,13 @@ describe('useMonitorListFilters', () => {
wrapper: WrappedHelper,
});
expect(result.current).toStrictEqual({ locations: [], tags: [], types: [] });
expect(result.current).toStrictEqual({
locations: [],
tags: [],
monitorTypes: [],
projects: [],
schedules: [],
});
await waitForNextUpdate();
@ -68,14 +98,22 @@ describe('useMonitorListFilters', () => {
{ label: 'Test 1', count: 1 },
{ label: 'Test 2', count: 2 },
],
tags: [
monitorTypes: [
{ label: 'Test 3', count: 3 },
{ label: 'Test 4', count: 4 },
],
types: [
projects: [
{ label: 'Test 5', count: 5 },
{ label: 'Test 6', count: 6 },
],
schedules: [
{ label: 'Test 7', count: 7 },
{ label: 'Test 8', count: 8 },
],
tags: [
{ label: 'Test 9', count: 9 },
{ label: 'Test 10', count: 10 },
],
});
});
});

View file

@ -0,0 +1,221 @@
/*
* 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 { useMemo, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-plugin/public';
import { ConfigKey } from '../../../../../../../common/runtime_types';
import { syntheticsMonitorType } from '../../../../../../../common/types/saved_objects';
import {
MonitorFilterState,
selectMonitorFiltersAndQueryState,
setOverviewPageStateAction,
updateManagementPageStateAction,
} from '../../../../state';
import { SyntheticsUrlParams } from '../../../../utils/url_params';
import { useUrlParams } from '../../../../hooks';
import {
SyntheticsMonitorFilterField,
getMonitorFilterFields,
getSyntheticsFilterKeyForLabel,
SyntheticsMonitorFilterChangeHandler,
} from './filter_fields';
const aggs = {
monitorTypes: {
terms: {
field: `${syntheticsMonitorType}.attributes.${ConfigKey.MONITOR_TYPE}.keyword`,
size: 10000,
},
},
tags: {
terms: {
field: `${syntheticsMonitorType}.attributes.${ConfigKey.TAGS}`,
size: 10000,
},
},
locations: {
terms: {
field: `${syntheticsMonitorType}.attributes.${ConfigKey.LOCATIONS}.id`,
size: 10000,
},
},
projects: {
terms: {
field: `${syntheticsMonitorType}.attributes.${ConfigKey.PROJECT_ID}`,
size: 10000,
},
},
schedules: {
terms: {
field: `${syntheticsMonitorType}.attributes.${ConfigKey.SCHEDULE}.number`,
size: 10000,
},
},
};
type Buckets = Array<{
key: string;
doc_count: number;
}>;
interface AggsResponse {
monitorTypes: {
buckets: Buckets;
};
locations: {
buckets: Buckets;
};
tags: {
buckets: Buckets;
};
projects: {
buckets: Buckets;
};
schedules: {
buckets: Buckets;
};
}
export const useFilters = (): Record<
SyntheticsMonitorFilterField,
Array<{ label: string; count: number }>
> => {
const { savedObjects } = useKibana().services;
const { data } = useFetcher(async () => {
return savedObjects?.client.find({
type: syntheticsMonitorType,
perPage: 0,
aggs,
});
}, []);
return useMemo(() => {
const { monitorTypes, tags, locations, projects, schedules } =
(data?.aggregations as AggsResponse) ?? {};
return {
monitorTypes:
monitorTypes?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
tags:
tags?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
locations:
locations?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
projects:
projects?.buckets
?.filter(({ key }) => key)
.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
schedules:
schedules?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
};
}, [data]);
};
type FilterFieldWithQuery = SyntheticsMonitorFilterField | 'query';
type FilterStateWithQuery = MonitorFilterState & { query?: string };
export function useMonitorFiltersState() {
const [getUrlParams, updateUrlParams] = useUrlParams();
const urlParams = getUrlParams();
const filterFieldsWithQuery: FilterFieldWithQuery[] = useMemo(() => {
const filterFields = getMonitorFilterFields();
return [...filterFields, 'query'];
}, []);
const dispatch = useDispatch();
const serializeFilterValue = useCallback(
(field: FilterFieldWithQuery, selectedValues: string[] | undefined) => {
if (field === 'query') {
return selectedValues?.length ? selectedValues.toString() : undefined;
}
return selectedValues && selectedValues.length > 0
? JSON.stringify(
selectedValues.map((value) => getSyntheticsFilterKeyForLabel(value, field))
)
: undefined;
},
[]
);
const serializeStateValues = useCallback(
(state: FilterStateWithQuery) => {
return filterFieldsWithQuery.reduce(
(acc, cur) => ({
...acc,
[cur]: serializeFilterValue(
cur as SyntheticsMonitorFilterField,
state[cur as SyntheticsMonitorFilterField]
),
}),
{}
);
},
[filterFieldsWithQuery, serializeFilterValue]
);
const handleFilterChange: SyntheticsMonitorFilterChangeHandler = useCallback(
(field: SyntheticsMonitorFilterField, selectedValues: string[] | undefined) => {
// Update url to reflect the changed filter
updateUrlParams({
[field]: serializeFilterValue(field, selectedValues),
});
},
[serializeFilterValue, updateUrlParams]
);
const reduxState = useSelector(selectMonitorFiltersAndQueryState);
const reduxStateSnapshot = JSON.stringify(serializeStateValues(reduxState));
const urlState = filterFieldsWithQuery.reduce(
(acc, cur) => ({ ...acc, [cur]: urlParams[cur as keyof SyntheticsUrlParams] }),
{}
);
const urlStateSerializedSnapshot = JSON.stringify(serializeStateValues(urlState));
const isUrlHydratedFromRedux = useRef(false);
useEffect(() => {
if (urlStateSerializedSnapshot !== reduxStateSnapshot) {
if (
urlStateSerializedSnapshot === '{}' &&
reduxStateSnapshot !== '{}' &&
!isUrlHydratedFromRedux.current
) {
// Hydrate url only during initialization
updateUrlParams(serializeStateValues(reduxState));
} else {
dispatch(updateManagementPageStateAction(urlState));
dispatch(setOverviewPageStateAction(urlState));
}
}
isUrlHydratedFromRedux.current = true;
// Only depend on the serialized snapshot
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlStateSerializedSnapshot, reduxStateSnapshot]);
return { handleFilterChange, filterState: reduxState };
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { EuiFieldSearch } from '@elastic/eui';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
@ -15,7 +15,7 @@ export function SearchField() {
const { query } = useGetUrlParams();
const [_, updateUrlParams] = useUrlParams();
const [search, setSearch] = useState(query || '');
const [search, setSearch] = useState<undefined | string>('');
useDebounce(
() => {
@ -27,13 +27,26 @@ export function SearchField() {
[search]
);
// Hydrate search input
const hasInputChangedRef = useRef(false);
useEffect(() => {
if (query && query !== search && !hasInputChangedRef.current) {
setSearch(query);
}
// Run only to sync url with input
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
return (
<EuiFieldSearch
css={{ minWidth: 230 }}
fullWidth
placeholder={PLACEHOLDER_TEXT}
value={search}
onChange={(e) => {
setSearch(e.target.value);
hasInputChangedRef.current = true;
setSearch(e.target.value ?? '');
}}
isClearable={true}
aria-label={PLACEHOLDER_TEXT}

View file

@ -14,8 +14,8 @@ import { WrappedHelper } from '../../../utils/testing';
import { SyntheticsAppState } from '../../../state/root_reducer';
import {
selectEncryptedSyntheticsSavedMonitors,
fetchMonitorListAction,
MonitorListPageState,
updateManagementPageStateAction,
MonitorFilterState,
} from '../../../state';
import { useMonitorList } from './use_monitor_list';
@ -23,7 +23,8 @@ import { useMonitorList } from './use_monitor_list';
describe('useMonitorList', () => {
let state: SyntheticsAppState;
let initialState: Omit<ReturnType<typeof useMonitorList>, 'loadPage' | 'reloadPage'>;
let defaultPageState: MonitorListPageState;
let filterState: MonitorFilterState;
let filterStateWithQuery: MonitorFilterState & { query?: string | undefined };
const dispatchMockFn = jest.fn();
beforeEach(() => {
@ -40,15 +41,12 @@ describe('useMonitorList', () => {
pageState: state.monitorList.pageState,
isDataQueried: false,
syntheticsMonitors: selectEncryptedSyntheticsSavedMonitors.resultFunc(state.monitorList),
overviewStatus: null,
handleFilterChange: jest.fn(),
};
defaultPageState = {
...state.monitorList.pageState,
query: '',
locations: [],
monitorType: [],
tags: [],
};
filterState = { locations: [], monitorTypes: [], projects: [], schedules: [], tags: [] };
filterStateWithQuery = { ...filterState, query: 'xyz' };
});
afterEach(() => {
@ -60,7 +58,7 @@ describe('useMonitorList', () => {
result: { current: hookResult },
} = renderHook(() => useMonitorList(), { wrapper: WrappedHelper });
expect(hookResult).toMatchObject(initialState);
expect(hookResult).toMatchObject({ ...initialState, handleFilterChange: expect.any(Function) });
});
it('dispatches correct action for query url param', async () => {
@ -79,18 +77,26 @@ describe('useMonitorList', () => {
renderHook(() => useMonitorList(), { wrapper: WrapperWithState });
expect(dispatchMockFn).toHaveBeenCalledWith(
fetchMonitorListAction.get({ ...defaultPageState, query })
updateManagementPageStateAction(filterStateWithQuery)
);
});
it('dispatches correct action for filter url param', async () => {
const tags = ['abc', 'xyz'];
const locations = ['loc1', 'loc1'];
const monitorType = ['browser'];
const exp = {
...filterStateWithQuery,
tags: ['abc', 'xyz'],
locations: ['loc1', 'loc1'],
monitorTypes: ['browser'],
schedules: ['browser'],
projects: ['proj-1'],
query: '',
};
const url = `/monitor/1?tags=${JSON.stringify(tags)}&locations=${JSON.stringify(
locations
)}&monitorType=${JSON.stringify(monitorType)}`;
const url = `/monitor/1?tags=${JSON.stringify(exp.tags)}&locations=${JSON.stringify(
exp.locations
)}&monitorTypes=${JSON.stringify(exp.monitorTypes)}&schedules=${JSON.stringify(
exp.schedules
)}&projects=${JSON.stringify(exp.projects)}`;
jest.useFakeTimers().setSystemTime(Date.now());
const WrapperWithState = ({ children }: { children: React.ReactElement }) => {
@ -103,8 +109,6 @@ describe('useMonitorList', () => {
renderHook(() => useMonitorList(), { wrapper: WrapperWithState });
expect(dispatchMockFn).toHaveBeenCalledWith(
fetchMonitorListAction.get({ ...defaultPageState, tags, locations, monitorType })
);
expect(dispatchMockFn).toHaveBeenCalledWith(updateManagementPageStateAction(exp));
});
});

View file

@ -5,16 +5,21 @@
* 2.0.
*/
import { useEffect, useCallback, useRef } from 'react';
import { useCallback, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useGetUrlParams } from '../../../hooks';
import { useDebounce } from 'react-use';
import {
fetchMonitorListAction,
quietFetchMonitorListAction,
fetchOverviewStatusAction,
MonitorListPageState,
selectEncryptedSyntheticsSavedMonitors,
selectMonitorListState,
selectOverviewStatus,
updateManagementPageStateAction,
} from '../../../state';
import { useSyntheticsRefreshContext } from '../../../contexts';
import { useMonitorFiltersState } from '../common/monitor_filters/use_filters';
export function useMonitorList() {
const dispatch = useDispatch();
@ -22,43 +27,44 @@ export function useMonitorList() {
const { pageState, loading, loaded, error, data } = useSelector(selectMonitorListState);
const syntheticsMonitors = useSelector(selectEncryptedSyntheticsSavedMonitors);
const { status: overviewStatus } = useSelector(selectOverviewStatus);
const { query, tags, monitorType, locations: locationFilters } = useGetUrlParams();
const { search } = useLocation();
const { handleFilterChange } = useMonitorFiltersState();
const { refreshInterval } = useSyntheticsRefreshContext();
const loadPage = useCallback(
(state: MonitorListPageState) =>
dispatch(
fetchMonitorListAction.get({
...state,
query,
tags,
monitorType,
locations: locationFilters,
})
),
[dispatch, locationFilters, monitorType, query, tags]
(state: MonitorListPageState) => {
dispatch(updateManagementPageStateAction(state));
},
[dispatch]
);
const reloadPage = useCallback(() => loadPage(pageState), [pageState, loadPage]);
useEffect(() => {
reloadPage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
const reloadPageQuiet = useCallback(() => {
dispatch(quietFetchMonitorListAction(pageState));
}, [dispatch, pageState]);
// Initial loading
// Periodically refresh
useEffect(() => {
if (!loading && !isDataQueriedRef.current) {
isDataQueriedRef.current = true;
reloadPage();
}
const intervalId = setInterval(() => {
reloadPageQuiet();
}, refreshInterval);
if (loading) {
isDataQueriedRef.current = true;
}
}, [reloadPage, syntheticsMonitors, loading]);
return () => {
clearInterval(intervalId);
};
}, [reloadPageQuiet, refreshInterval]);
useDebounce(
() => {
const overviewStatusArgs = { ...pageState, perPage: pageState.pageSize };
dispatch(fetchOverviewStatusAction.get(overviewStatusArgs));
dispatch(fetchMonitorListAction.get(pageState));
},
500,
[pageState]
);
return {
loading,
@ -71,5 +77,7 @@ export function useMonitorList() {
reloadPage,
isDataQueried: isDataQueriedRef.current,
absoluteTotal: data.absoluteTotal ?? 0,
overviewStatus,
handleFilterChange,
};
}

View file

@ -1,46 +0,0 @@
/*
* 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, { useState } from 'react';
import { FieldValueSelection } from '@kbn/observability-plugin/public';
import { FilterItem } from './filter_group';
import { useGetUrlParams, useUrlParams } from '../../../../hooks';
export const FilterButton = ({ filter }: { filter: FilterItem }) => {
const { label, values, field } = filter;
const [query, setQuery] = useState('');
const [, updateUrlParams] = useUrlParams();
const urlParams = useGetUrlParams();
return (
<FieldValueSelection
selectedValue={urlParams[field] || []}
singleSelection={false}
label={label}
values={
query
? values.filter(({ label: str }) => str.toLowerCase().includes(query.toLowerCase()))
: values
}
setQuery={setQuery}
onChange={(selectedValues) => {
updateUrlParams({
[field]:
selectedValues && selectedValues.length > 0
? JSON.stringify(selectedValues)
: undefined,
});
}}
allowExclusions={false}
loading={false}
asFilterButton={true}
/>
);
};

View file

@ -1,82 +0,0 @@
/*
* 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 { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-plugin/public';
import { useMemo } from 'react';
import { syntheticsMonitorType } from '../../../../../../../common/types/saved_objects';
const aggs = {
types: {
terms: {
field: `${syntheticsMonitorType}.attributes.type.keyword`,
size: 10000,
},
},
tags: {
terms: {
field: `${syntheticsMonitorType}.attributes.tags`,
size: 10000,
},
},
locations: {
terms: {
field: `${syntheticsMonitorType}.attributes.locations.id`,
size: 10000,
},
},
};
type Buckets = Array<{
key: string;
doc_count: number;
}>;
interface AggsResponse {
types: {
buckets: Buckets;
};
locations: {
buckets: Buckets;
};
tags: {
buckets: Buckets;
};
}
export const useFilters = () => {
const { savedObjects } = useKibana().services;
const { data } = useFetcher(async () => {
return savedObjects?.client.find({
type: syntheticsMonitorType,
perPage: 0,
aggs,
});
}, []);
return useMemo(() => {
const { types, tags, locations } = (data?.aggregations as AggsResponse) ?? {};
return {
types:
types?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
tags:
tags?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
locations:
locations?.buckets?.map(({ key, doc_count: count }) => ({
label: key,
count,
})) ?? [],
};
}, [data]);
};

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import React, { useMemo, useEffect } from 'react';
import React from 'react';
import { EuiSpacer } from '@elastic/eui';
import type { useMonitorList } from '../hooks/use_monitor_list';
import { MonitorAsyncError } from './monitor_errors/monitor_async_error';
import { useOverviewStatus } from '../hooks/use_overview_status';
import { ListFilters } from './list_filters/list_filters';
import { ListFilters } from '../common/monitor_filters/list_filters';
import { MonitorList } from './monitor_list_table/monitor_list';
import { MonitorStats } from './monitor_stats/monitor_stats';
@ -31,6 +30,8 @@ export const MonitorListContainer = ({
absoluteTotal,
loadPage,
reloadPage,
overviewStatus,
handleFilterChange,
} = monitorListProps;
// TODO: Display inline errors in the management table
@ -41,18 +42,6 @@ export const MonitorListContainer = ({
// sortOrder: pageState.sortOrder,
// });
const overviewStatusArgs = useMemo(() => {
return {
pageState: { ...pageState, perPage: pageState.pageSize },
};
}, [pageState]);
const { status, reload: reloadStatus } = useOverviewStatus(overviewStatusArgs);
useEffect(() => {
reloadStatus();
}, [reloadStatus, syntheticsMonitors]);
if (!isEnabled && absoluteTotal === 0) {
return null;
}
@ -60,9 +49,9 @@ export const MonitorListContainer = ({
return (
<>
<MonitorAsyncError />
<ListFilters />
<ListFilters handleFilterChange={handleFilterChange} />
<EuiSpacer />
<MonitorStats status={status} />
<MonitorStats overviewStatus={overviewStatus} />
<EuiSpacer />
<MonitorList
syntheticsMonitors={syntheticsMonitors}
@ -72,7 +61,7 @@ export const MonitorListContainer = ({
loading={monitorsLoading}
loadPage={loadPage}
reloadPage={reloadPage}
status={status}
overviewStatus={overviewStatus}
/>
</>
);

View file

@ -34,15 +34,13 @@ import { MonitorLocations } from './monitor_locations';
export function useMonitorListColumns({
canEditSynthetics,
reloadPage,
loading,
status,
overviewStatus,
setMonitorPendingDeletion,
}: {
canEditSynthetics: boolean;
loading: boolean;
status: OverviewStatusState | null;
reloadPage: () => void;
overviewStatus: OverviewStatusState | null;
setMonitorPendingDeletion: (config: EncryptedSyntheticsSavedMonitor) => void;
}): Array<EuiBasicTableColumn<EncryptedSyntheticsSavedMonitor>> {
const history = useHistory();
@ -66,7 +64,7 @@ export function useMonitorListColumns({
),
},
// Only show Project ID column if project monitors are present
...(status?.projectMonitorsCount ?? 0 > 0
...(overviewStatus?.projectMonitorsCount ?? 0 > 0
? [
{
align: 'left' as const,
@ -91,7 +89,7 @@ export function useMonitorListColumns({
ariaLabel={labels.getFilterForTypeMessage(monitor[ConfigKey.MONITOR_TYPE])}
onClick={() => {
history.push({
search: `monitorType=${encodeURIComponent(
search: `monitorTypes=${encodeURIComponent(
JSON.stringify([monitor[ConfigKey.MONITOR_TYPE]])
)}`,
});
@ -119,7 +117,7 @@ export function useMonitorListColumns({
<MonitorLocations
monitorId={monitor[ConfigKey.CONFIG_ID] ?? monitor.id}
locations={locations}
status={status}
status={overviewStatus}
/>
) : null,
},
@ -149,7 +147,7 @@ export function useMonitorListColumns({
<MonitorEnabled
configId={monitor[ConfigKey.CONFIG_ID]}
monitor={monitor}
reloadPage={reloadPage}
reloadPage={() => {}}
isSwitchable={!loading}
/>
),

View file

@ -37,7 +37,7 @@ interface Props {
loading: boolean;
loadPage: (state: MonitorListPageState) => void;
reloadPage: () => void;
status: OverviewStatusState | null;
overviewStatus: OverviewStatusState | null;
}
export const MonitorList = ({
@ -46,7 +46,7 @@ export const MonitorList = ({
total,
error,
loading,
status,
overviewStatus,
loadPage,
reloadPage,
}: Props) => {
@ -98,8 +98,7 @@ export const MonitorList = ({
const columns = useMonitorListColumns({
canEditSynthetics,
loading,
reloadPage,
status,
overviewStatus,
setMonitorPendingDeletion,
});

View file

@ -24,7 +24,11 @@ import * as labels from '../labels';
import { MonitorTestRunsCount } from './monitor_test_runs';
import { MonitorTestRunsSparkline } from './monitor_test_runs_sparkline';
export const MonitorStats = ({ status }: { status: OverviewStatusState | null }) => {
export const MonitorStats = ({
overviewStatus,
}: {
overviewStatus: OverviewStatusState | null;
}) => {
const { euiTheme } = useEuiTheme();
return (
@ -43,11 +47,11 @@ export const MonitorStats = ({ status }: { status: OverviewStatusState | null })
<EuiFlexItem css={{ display: 'flex', flexDirection: 'row', gap: euiTheme.size.l }}>
<MonitorStat
description={labels.CONFIGURATIONS_LABEL}
value={status?.allMonitorsCount}
value={overviewStatus?.allMonitorsCount}
/>
<MonitorStat
description={labels.DISABLED_LABEL}
value={status?.disabledMonitorsCount}
value={overviewStatus?.disabledMonitorsCount}
/>
</EuiFlexItem>
</EuiPanel>
@ -64,9 +68,9 @@ export const MonitorStats = ({ status }: { status: OverviewStatusState | null })
<EuiFlexItem
css={{ display: 'flex', flexDirection: 'row', gap: euiTheme.size.l, height: '200px' }}
>
<MonitorTestRunsCount />
<MonitorTestRunsCount monitorIds={overviewStatus?.allIds ?? []} />
<EuiFlexItem grow={true}>
<MonitorTestRunsSparkline />
<MonitorTestRunsSparkline monitorIds={overviewStatus?.allIds ?? []} />
</EuiFlexItem>
</EuiFlexItem>
</EuiPanel>

View file

@ -15,7 +15,7 @@ import { useAbsoluteDate } from '../../../../hooks';
import { ClientPluginsStart } from '../../../../../../plugin';
import * as labels from '../labels';
export const MonitorTestRunsCount = () => {
export const MonitorTestRunsCount = ({ monitorIds }: { monitorIds: string[] }) => {
const { observability } = useKibana<ClientPluginsStart>().services;
const theme = useTheme();
@ -31,11 +31,11 @@ export const MonitorTestRunsCount = () => {
{
time: { from: absFrom, to: absTo },
reportDefinitions: {
'monitor.id': [],
'observer.geo.name': [],
'monitor.id': monitorIds.length > 0 ? monitorIds : ['false-monitor-id'], // Show no data when monitorIds is empty
},
dataType: 'synthetics',
selectedMetricField: 'monitor_total_runs',
filters: [],
name: labels.TEST_RUNS_LABEL,
color: theme.eui.euiColorVis1,
},

View file

@ -14,7 +14,7 @@ import { useAbsoluteDate } from '../../../../hooks';
import { ClientPluginsStart } from '../../../../../../plugin';
import * as labels from '../labels';
export const MonitorTestRunsSparkline = () => {
export const MonitorTestRunsSparkline = ({ monitorIds }: { monitorIds: string[] }) => {
const { observability } = useKibana<ClientPluginsStart>().services;
const { ExploratoryViewEmbeddable } = observability;
@ -34,11 +34,11 @@ export const MonitorTestRunsSparkline = () => {
seriesType: 'area',
time: { from, to },
reportDefinitions: {
'monitor.id': [],
'observer.geo.name': [],
'monitor.id': monitorIds.length > 0 ? monitorIds : ['false-monitor-id'], // Show no data when monitorIds is empty
},
dataType: 'synthetics',
selectedMetricField: 'monitor.check_group',
filters: [],
name: labels.TEST_RUNS_LABEL,
color: theme.eui.euiColorVis1,
operationType: 'unique_count',

View file

@ -30,6 +30,13 @@ const MonitorPage: React.FC = () => {
useMonitorListBreadcrumbs();
const {
error: enablementError,
enablement: { isEnabled, canEnable },
loading: enablementLoading,
enableSynthetics,
} = useEnablement();
const monitorListProps = useMonitorList();
const {
syntheticsMonitors,
@ -38,13 +45,6 @@ const MonitorPage: React.FC = () => {
absoluteTotal,
} = monitorListProps;
const {
error: enablementError,
enablement: { isEnabled, canEnable },
loading: enablementLoading,
enableSynthetics,
} = useEnablement();
const { loading: locationsLoading } = useLocations();
const showEmptyState = isEnabled !== undefined && syntheticsMonitors.length === 0;

View file

@ -9,13 +9,13 @@ import { EuiFlexGroup, EuiSpacer, EuiFlexItem } from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { Redirect, useLocation } from 'react-router-dom';
import { FilterGroup } from '../common/monitor_filters/filter_group';
import { OverviewAlerts } from './overview/overview_alerts';
import { useEnablement, useGetUrlParams } from '../../../hooks';
import { useEnablement } from '../../../hooks';
import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context';
import {
fetchMonitorOverviewAction,
quietFetchOverviewAction,
setOverviewPageStateAction,
selectOverviewPageState,
selectServiceLocationsState,
} from '../../../state';
@ -40,7 +40,6 @@ export const OverviewPage: React.FC = () => {
const dispatch = useDispatch();
const { lastRefresh } = useSyntheticsRefreshContext();
const { query } = useGetUrlParams();
const { search } = useLocation();
const pageState = useSelector(selectOverviewPageState);
@ -52,14 +51,6 @@ export const OverviewPage: React.FC = () => {
}
}, [dispatch, locationsLoaded, locationsLoading]);
// fetch overview for query state changes
useEffect(() => {
if (pageState.query !== query) {
dispatch(fetchMonitorOverviewAction.get({ ...pageState, query }));
dispatch(setOverviewPageStateAction({ query }));
}
}, [dispatch, pageState, query]);
// fetch overview for all other page state changes
useEffect(() => {
dispatch(fetchMonitorOverviewAction.get(pageState));
@ -75,13 +66,19 @@ export const OverviewPage: React.FC = () => {
loading: enablementLoading,
} = useEnablement();
const { syntheticsMonitors, loading: monitorsLoading, loaded: monitorsLoaded } = useMonitorList();
const {
syntheticsMonitors,
loading: monitorsLoading,
loaded: monitorsLoaded,
handleFilterChange,
} = useMonitorList();
if (
!search &&
!enablementLoading &&
isEnabled &&
!monitorsLoading &&
monitorsLoaded &&
syntheticsMonitors.length === 0
) {
return <Redirect to={GETTING_STARTED_ROUTE} />;
@ -99,13 +96,16 @@ export const OverviewPage: React.FC = () => {
return (
<>
<EuiFlexGroup>
<EuiFlexGroup gutterSize="s" wrap={true}>
<EuiFlexItem>
<SearchField />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<QuickFilters />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FilterGroup handleFilterChange={handleFilterChange} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{Boolean(!monitorsLoaded || syntheticsMonitors?.length > 0) && (

View file

@ -5,16 +5,10 @@
* 2.0.
*/
import { ErrorToastOptions } from '@kbn/core-notifications-browser';
import { createAction } from '@reduxjs/toolkit';
import { UpsertMonitorResponse } from '..';
import {
EncryptedSyntheticsMonitor,
MonitorManagementListResult,
SyntheticsMonitor,
} from '../../../../../common/runtime_types';
import { UpsertMonitorError, UpsertMonitorRequest, UpsertMonitorResponse } from '..';
import { MonitorManagementListResult } from '../../../../../common/runtime_types';
import { createAsyncAction } from '../utils/actions';
import { IHttpSerializedFetchError } from '../utils/http_error';
import { MonitorListPageState } from './models';
@ -22,29 +16,9 @@ export const fetchMonitorListAction = createAsyncAction<
MonitorListPageState,
MonitorManagementListResult
>('fetchMonitorListAction');
interface ToastParams<MessageType> {
message: MessageType;
lifetimeMs: number;
testAttribute?: string;
}
export interface UpsertMonitorRequest {
configId: string;
monitor: Partial<SyntheticsMonitor> | Partial<EncryptedSyntheticsMonitor>;
success: ToastParams<string>;
error: ToastParams<ErrorToastOptions>;
/**
* The effect will perform a quiet refresh of the overview state
* after a successful upsert. The default behavior is to perform the fetch.
*/
shouldQuietFetchAfterSuccess?: boolean;
}
interface UpsertMonitorError {
configId: string;
error: IHttpSerializedFetchError;
}
export const quietFetchMonitorListAction = createAction<MonitorListPageState>(
'quietFetchMonitorListAction'
);
export const fetchUpsertMonitorAction = createAction<UpsertMonitorRequest>('fetchUpsertMonitor');
export const fetchUpsertSuccessAction = createAction<{
@ -62,3 +36,7 @@ export const enableMonitorAlertAction = createAsyncAction<
>('enableMonitorAlertAction');
export const clearMonitorUpsertStatus = createAction<string>('clearMonitorUpsertStatus');
export const updateManagementPageStateAction = createAction<Partial<MonitorListPageState>>(
'updateManagementPageState'
);

View file

@ -31,7 +31,9 @@ function toMonitorManagementListQueryArgs(
query: pageState.query,
tags: pageState.tags,
locations: pageState.locations,
monitorType: pageState.monitorType,
monitorTypes: pageState.monitorTypes,
projects: pageState.projects,
schedules: pageState.schedules,
searchFields: [],
};
}

View file

@ -6,9 +6,10 @@
*/
import { PayloadAction } from '@reduxjs/toolkit';
import { call, put, takeEvery, takeLeading, select } from 'redux-saga/effects';
import { call, put, takeEvery, select, debounce } from 'redux-saga/effects';
import { SavedObject } from '@kbn/core-saved-objects-common';
import { enableDefaultAlertingAction } from '../alert_rules';
import { ConfigKey, SyntheticsMonitor } from '../../../../../common/runtime_types';
import { kibanaService } from '../../../../utils/kibana_service';
import { MonitorOverviewPageState, quietFetchOverviewStatusAction } from '../overview';
import { quietFetchOverviewAction } from '../overview/actions';
@ -22,15 +23,16 @@ import {
fetchUpsertFailureAction,
fetchUpsertMonitorAction,
fetchUpsertSuccessAction,
UpsertMonitorRequest,
quietFetchMonitorListAction,
} from './actions';
import { fetchMonitorManagementList, fetchUpsertMonitor } from './api';
import { toastTitle } from './toast_title';
import { ConfigKey, SyntheticsMonitor } from '../../../../../common/runtime_types';
import { UpsertMonitorRequest } from './models';
export function* fetchMonitorListEffect() {
yield takeLeading(
fetchMonitorListAction.get,
yield debounce(
300, // Only take the latest while ignoring any intermediate triggers
[fetchMonitorListAction.get, quietFetchMonitorListAction],
fetchEffectFactory(
fetchMonitorManagementList,
fetchMonitorListAction.success,

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { isEqual } from 'lodash';
import { createReducer } from '@reduxjs/toolkit';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
@ -26,6 +25,7 @@ import {
fetchUpsertFailureAction,
fetchUpsertMonitorAction,
fetchUpsertSuccessAction,
updateManagementPageStateAction,
} from './actions';
export interface MonitorListState {
@ -56,10 +56,10 @@ const initialState: MonitorListState = {
export const monitorListReducer = createReducer(initialState, (builder) => {
builder
.addCase(fetchMonitorListAction.get, (state, action) => {
if (!isEqual(state.pageState, action.payload)) {
state.pageState = action.payload;
}
.addCase(updateManagementPageStateAction, (state, action) => {
state.pageState = { ...state.pageState, ...action.payload };
})
.addCase(fetchMonitorListAction.get, (state) => {
state.loading = true;
state.loaded = false;
})

View file

@ -5,20 +5,54 @@
* 2.0.
*/
import { ErrorToastOptions } from '@kbn/core-notifications-browser';
import {
EncryptedSyntheticsMonitor,
EncryptedSyntheticsSavedMonitor,
FetchMonitorManagementListQueryArgs,
SyntheticsMonitor,
} from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '../utils/http_error';
export type MonitorListSortField = `${keyof EncryptedSyntheticsSavedMonitor}.keyword` | 'enabled';
export interface MonitorListPageState {
export interface MonitorFilterState {
tags?: string[];
monitorTypes?: string[];
projects?: string[];
schedules?: string[];
locations?: string[];
}
export interface MonitorListPageState extends MonitorFilterState {
query?: string;
pageIndex: number;
pageSize: number;
sortField: MonitorListSortField;
sortOrder: NonNullable<FetchMonitorManagementListQueryArgs['sortOrder']>;
tags?: string[];
monitorType?: string[];
locations?: string[];
}
interface ToastParams<MessageType> {
message: MessageType;
lifetimeMs: number;
testAttribute?: string;
}
export interface UpsertMonitorRequest {
configId: string;
monitor: Partial<SyntheticsMonitor> | Partial<EncryptedSyntheticsMonitor>;
success: ToastParams<string>;
error: ToastParams<ErrorToastOptions>;
/**
* The effect will perform a quiet refresh of the overview state
* after a successful upsert. The default behavior is to perform the fetch.
*/
shouldQuietFetchAfterSuccess?: boolean;
}
export interface UpsertMonitorError {
configId: string;
error: IHttpSerializedFetchError;
}

View file

@ -9,6 +9,7 @@ import { createSelector } from 'reselect';
import { ConfigKey, EncryptedSyntheticsSavedMonitor } from '../../../../../common/runtime_types';
import { SyntheticsAppState } from '../root_reducer';
import { MonitorFilterState } from './models';
export const selectMonitorListState = (state: SyntheticsAppState) => state.monitorList;
export const selectEncryptedSyntheticsSavedMonitors = createSelector(
@ -21,6 +22,15 @@ export const selectEncryptedSyntheticsSavedMonitors = createSelector(
created_at: monitor.created_at,
})) as EncryptedSyntheticsSavedMonitor[]
);
export const selectMonitorFiltersAndQueryState = createSelector(selectMonitorListState, (state) => {
const { monitorTypes, tags, locations, projects, schedules }: MonitorFilterState =
state.pageState;
const { query } = state.pageState;
return { monitorTypes, tags, locations, projects, schedules, query };
});
export const selectMonitorUpsertStatuses = (state: SyntheticsAppState) =>
state.monitorList.monitorUpsertStatuses;

View file

@ -32,7 +32,9 @@ function toMonitorOverviewQueryArgs(
query: pageState.query,
tags: pageState.tags,
locations: pageState.locations,
monitorType: pageState.monitorType,
projects: pageState.projects,
schedules: pageState.schedules,
monitorTypes: pageState.monitorTypes,
sortField: pageState.sortField,
sortOrder: pageState.sortOrder,
searchFields: [],

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { takeLatest, takeLeading } from 'redux-saga/effects';
import { takeLatest, debounce } from 'redux-saga/effects';
import { fetchEffectFactory } from '../utils/fetch_effect';
import {
fetchMonitorOverviewAction,
@ -16,7 +16,8 @@ import {
import { fetchMonitorOverview, fetchOverviewStatus } from './api';
export function* fetchMonitorOverviewEffect() {
yield takeLeading(
yield debounce(
300, // Only take the latest while ignoring any intermediate triggers
[fetchMonitorOverviewAction.get, quietFetchOverviewAction.get],
fetchEffectFactory(
fetchMonitorOverview,

View file

@ -7,13 +7,11 @@
import { MonitorOverviewResult, OverviewStatusState } from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '../utils/http_error';
import { MonitorFilterState } from '../monitor_list';
export interface MonitorOverviewPageState {
export interface MonitorOverviewPageState extends MonitorFilterState {
perPage: number;
query?: string;
tags?: string[];
monitorType?: string[];
locations?: string[];
sortOrder: 'asc' | 'desc';
sortField: string;
}

View file

@ -67,7 +67,8 @@ export const mockState: SyntheticsAppState = {
sortField: `${ConfigKey.NAME}.keyword`,
sortOrder: 'asc',
tags: undefined,
monitorType: undefined,
monitorTypes: undefined,
projects: undefined,
locations: undefined,
},
monitorUpsertStatuses: {},

View file

@ -71,7 +71,9 @@ describe('getSupportedUrlParams', () => {
statusFilter: STATUS_FILTER,
query: '',
locations: [],
monitorType: [],
monitorTypes: [],
projects: [],
schedules: [],
tags: [],
});
});

View file

@ -28,9 +28,11 @@ export interface SyntheticsUrlParams {
query?: string;
tags?: string[];
locations?: string[];
monitorType?: string[];
monitorTypes?: string[];
status?: string[];
locationId?: string;
projects?: string[];
schedules?: string[];
}
const {
@ -87,9 +89,11 @@ export const getSupportedUrlParams = (params: {
focusConnectorField,
query,
tags,
monitorType,
monitorTypes,
locations,
locationId,
projects,
schedules,
} = filteredParams;
return {
@ -114,8 +118,10 @@ export const getSupportedUrlParams = (params: {
focusConnectorField: !!focusConnectorField,
query: query || '',
tags: tags ? JSON.parse(tags) : [],
monitorType: monitorType ? JSON.parse(monitorType) : [],
monitorTypes: monitorTypes ? JSON.parse(monitorTypes) : [],
locations: locations ? JSON.parse(locations) : [],
projects: projects ? JSON.parse(projects) : [],
schedules: schedules ? JSON.parse(schedules) : [],
locationId: locationId || undefined,
};
};

View file

@ -153,6 +153,7 @@ export class StatusRuleExecutor {
allMonitorsCount: allIds.length,
disabledMonitorsCount: allIds.length,
projectMonitorsCount,
allIds,
};
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { intersection } from 'lodash';
import { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { SUMMARY_FILTER } from '../../common/constants/client_defaults';
import { UptimeEsClient } from '../legacy_uptime/lib/lib';
@ -117,7 +118,6 @@ export async function queryMonitorStatus(
for await (const response of promises) {
response.body.aggregations?.id.buckets.forEach(
({ location, key: queryId }: { location: any; key: string }) => {
const monLocations = monitorLocationsMap?.[queryId];
const locationSummaries = location.buckets.map(
({ status, key: locationName }: { key: string; status: any }) => {
const ping = status.hits.hits[0]._source as Ping & { '@timestamp': string };
@ -125,8 +125,11 @@ export async function queryMonitorStatus(
}
) as Array<{ location: string; ping: Ping & { '@timestamp': string } }>;
// discard any locations that are not in the monitorLocationsMap for the given monitor
monLocations?.forEach((monLocation) => {
// discard any locations that are not in the monitorLocationsMap for the given monitor as well as those which are
// in monitorLocationsMap but not in listOfLocations
const monLocations = monitorLocationsMap?.[queryId];
const monQueriedLocations = intersection(monLocations, listOfLocations);
monQueriedLocations?.forEach((monLocation) => {
const locationSummary = locationSummaries.find(
(summary) => summary.location === monLocation
);
@ -166,5 +169,5 @@ export async function queryMonitorStatus(
}
);
}
return { up, down, pending, upConfigs, downConfigs, enabledIds: ids };
return { up, down, pending, upConfigs, downConfigs, enabledIds: ids, allIds: ids };
}

View file

@ -20,8 +20,10 @@ export const QuerySchema = schema.object({
query: schema.maybe(schema.string()),
filter: schema.maybe(schema.string()),
tags: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
monitorType: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
monitorTypes: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
locations: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
projects: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
schedules: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
status: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
fields: schema.maybe(schema.arrayOf(schema.string())),
searchAfter: schema.maybe(schema.arrayOf(schema.string())),
@ -51,19 +53,23 @@ export const getMonitors = (
sortOrder,
query,
tags,
monitorType,
monitorTypes,
locations,
filter = '',
fields,
searchAfter,
projects,
schedules,
} = request as MonitorsQuery;
const filterStr = getMonitorFilters({
filter,
monitorTypes: monitorType,
monitorTypes,
tags,
locations,
serviceLocations: syntheticsService.locations,
projects,
schedules,
});
return savedObjectsClient.find({
@ -85,13 +91,17 @@ export const getMonitorFilters = ({
ports,
filter,
locations,
projects,
monitorTypes,
schedules,
serviceLocations,
}: {
filter?: string;
tags?: string | string[];
monitorTypes?: string | string[];
locations?: string | string[];
projects?: string | string[];
schedules?: string | string[];
ports?: string | string[];
serviceLocations: ServiceLocations;
}) => {
@ -100,8 +110,10 @@ export const getMonitorFilters = ({
return [
filter,
getKqlFilter({ field: 'tags', values: tags }),
getKqlFilter({ field: 'project_id', values: projects }),
getKqlFilter({ field: 'type', values: monitorTypes }),
getKqlFilter({ field: 'locations.id', values: locationFilter }),
getKqlFilter({ field: 'schedule.number', values: schedules }),
]
.filter((f) => !!f)
.join(' AND ');
@ -129,7 +141,7 @@ export const getKqlFilter = ({
}
if (Array.isArray(values)) {
return `${fieldKey}:"${values.join(`" ${operator} ${fieldKey}:"`)}"`;
return ` (${fieldKey}:"${values.join(`" ${operator} ${fieldKey}:"`)}" )`;
}
return `${fieldKey}:"${values}"`;
@ -143,7 +155,7 @@ const parseLocationFilter = (serviceLocations: ServiceLocations, locations?: str
if (Array.isArray(locations)) {
return locations
.map((loc) => findLocationItem(loc, serviceLocations)?.id ?? '')
.filter((val) => !val);
.filter((val) => !!val);
}
return findLocationItem(locations, serviceLocations)?.id ?? '';
@ -159,15 +171,18 @@ export const findLocationItem = (query: string, locations: ServiceLocations) =>
* @param monitorQuery { MonitorsQuery }
*/
export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => {
const { query, tags, monitorType, locations, status, filter } = monitorQuery;
const { query, tags, monitorTypes, locations, status, filter, projects, schedules } =
monitorQuery;
return (
!!query ||
!!filter ||
!!locations?.length ||
!!monitorType?.length ||
!!monitorTypes?.length ||
!!tags?.length ||
!!status?.length
!!status?.length ||
!!projects?.length ||
!!schedules?.length
);
};

View file

@ -6,14 +6,26 @@
*/
import { schema } from '@kbn/config-schema';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import { getAllMonitors } from '../../saved_objects/synthetics_monitor/get_all_monitors';
import { isStatusEnabled } from '../../../common/runtime_types/monitor_management/alert_config';
import { ConfigKey, MonitorOverviewItem, SyntheticsMonitor } from '../../../common/runtime_types';
import {
ConfigKey,
EncryptedSyntheticsMonitor,
MonitorOverviewItem,
} from '../../../common/runtime_types';
import { UMServerLibs } from '../../legacy_uptime/lib/lib';
import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes/types';
import { API_URLS, SYNTHETICS_API_URLS } from '../../../common/constants';
import { syntheticsMonitorType } from '../../legacy_uptime/lib/saved_objects/synthetics_monitor';
import { getMonitorNotFoundResponse } from '../synthetics_service/service_errors';
import { getMonitors, isMonitorsQueryFiltered, QuerySchema, SEARCH_FIELDS } from '../common';
import {
getMonitorFilters,
getMonitors,
isMonitorsQueryFiltered,
MonitorsQuery,
QuerySchema,
SEARCH_FIELDS,
} from '../common';
export const getSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
@ -89,13 +101,24 @@ export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory =
validate: {
query: QuerySchema,
},
handler: async ({ request, savedObjectsClient }): Promise<any> => {
const { sortField, sortOrder, query } = request.query;
const finder = savedObjectsClient.createPointInTimeFinder<SyntheticsMonitor>({
type: syntheticsMonitorType,
sortField: sortField === 'status' ? `${ConfigKey.NAME}.keyword` : sortField,
handler: async ({ request, savedObjectsClient, syntheticsMonitorClient }): Promise<any> => {
const {
sortField,
sortOrder,
perPage: 1000,
query,
locations: queriedLocations,
} = request.query as MonitorsQuery;
const filtersStr = getMonitorFilters({
...request.query,
serviceLocations: syntheticsMonitorClient.syntheticsService.locations,
});
const allMonitorConfigs = await getAllMonitors({
sortOrder,
filter: filtersStr,
soClient: savedObjectsClient,
sortField: sortField === 'status' ? `${ConfigKey.NAME}.keyword` : sortField,
search: query ? `${query}*` : undefined,
searchFields: SEARCH_FIELDS,
});
@ -104,29 +127,13 @@ export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory =
let total = 0;
const allMonitors: MonitorOverviewItem[] = [];
for await (const result of finder.find()) {
/* collect all monitor ids for use
* in filtering overview requests */
result.saved_objects.forEach((monitor) => {
const id = monitor.attributes[ConfigKey.MONITOR_QUERY_ID];
const configId = monitor.attributes[ConfigKey.CONFIG_ID];
allMonitorIds.push(configId);
for (const { attributes } of allMonitorConfigs) {
const configId = attributes[ConfigKey.CONFIG_ID];
allMonitorIds.push(configId);
/* for each location, add a config item */
const locations = monitor.attributes[ConfigKey.LOCATIONS];
locations.forEach((location) => {
const config = {
id,
configId,
name: monitor.attributes[ConfigKey.NAME],
location,
isEnabled: monitor.attributes[ConfigKey.ENABLED],
isStatusAlertEnabled: isStatusEnabled(monitor.attributes[ConfigKey.ALERT_CONFIG]),
};
allMonitors.push(config);
total++;
});
});
const monitorConfigsPerLocation = getOverviewConfigsPerLocation(attributes, queriedLocations);
allMonitors.push(...monitorConfigsPerLocation);
total += monitorConfigsPerLocation.length;
}
return {
@ -136,3 +143,34 @@ export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory =
};
},
});
function getOverviewConfigsPerLocation(
attributes: EncryptedSyntheticsMonitor,
queriedLocations: string | string[] | undefined
) {
const id = attributes[ConfigKey.MONITOR_QUERY_ID];
const configId = attributes[ConfigKey.CONFIG_ID];
/* for each location, add a config item */
const locations = attributes[ConfigKey.LOCATIONS];
const queriedLocationsArray =
queriedLocations && !Array.isArray(queriedLocations) ? [queriedLocations] : queriedLocations;
/* exclude nob matching locations if location filter is present */
const filteredLocations = queriedLocationsArray?.length
? locations.filter(
(loc) =>
(loc.label && queriedLocationsArray.includes(loc.label)) ||
queriedLocationsArray.includes(loc.id)
)
: locations;
return filteredLocations.map((location) => ({
id,
configId,
name: attributes[ConfigKey.NAME],
location,
isEnabled: attributes[ConfigKey.ENABLED],
isStatusAlertEnabled: isStatusEnabled(attributes[ConfigKey.ALERT_CONFIG]),
}));
}

View file

@ -178,6 +178,7 @@ describe('current status route', () => {
pending: 0,
down: 1,
enabledIds: ['id1', 'id2'],
allIds: ['id1', 'id2'],
up: 2,
upConfigs: {
'id1-Asia/Pacific - Japan': {
@ -321,18 +322,24 @@ describe('current status route', () => {
*
* The expectation here is we will send the test client two separate "requests", one for each of the two IDs.
*/
const concernedLocations = [
'Asia/Pacific - Japan',
'Europe - Germany',
'Asia/Pacific - Japan',
];
expect(
await queryMonitorStatus(
uptimeEsClient,
times(10000).map((n) => 'Europe - Germany' + n),
[...concernedLocations, ...times(9997).map((n) => 'Europe - Germany' + n)],
{ from: 2500, to: 'now' },
['id1', 'id2'],
{ id1: ['Asia/Pacific - Japan'], id2: ['Europe - Germany', 'Asia/Pacific - Japan'] }
{ id1: [concernedLocations[0]], id2: [concernedLocations[1], concernedLocations[2]] }
)
).toEqual({
pending: 0,
down: 1,
enabledIds: ['id1', 'id2'],
allIds: ['id1', 'id2'],
up: 2,
upConfigs: {
'id1-Asia/Pacific - Japan': {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { intersection } from 'lodash';
import datemath, { Unit } from '@kbn/datemath';
import { SavedObjectsClientContract } from '@kbn/core/server';
import {
@ -19,7 +20,7 @@ import { SyntheticsRestApiRouteFactory } from '../../legacy_uptime/routes';
import { UptimeEsClient } from '../../legacy_uptime/lib/lib';
import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client';
import { ConfigKey } from '../../../common/runtime_types';
import { QuerySchema, MonitorsQuery } from '../common';
import { QuerySchema, MonitorsQuery, getMonitorFilters } from '../common';
/**
* Helper function that converts a monitor's schedule to a value to use to generate
@ -46,7 +47,7 @@ export async function getStatus(
syntheticsMonitorClient: SyntheticsMonitorClient,
params: MonitorsQuery
) {
const { query } = params;
const { query, locations: queryLocations } = params;
/**
* Walk through all monitor saved objects, bucket IDs by disabled/enabled status.
*
@ -54,9 +55,14 @@ export async function getStatus(
* latest ping for all enabled monitors.
*/
const filtersStr = getMonitorFilters({
...params,
serviceLocations: syntheticsMonitorClient.syntheticsService.locations,
});
const allMonitors = await getAllMonitors({
soClient,
search: query ? `${query}*` : undefined,
filter: filtersStr,
fields: [
ConfigKey.ENABLED,
ConfigKey.LOCATIONS,
@ -67,6 +73,7 @@ export async function getStatus(
});
const {
allIds,
enabledIds,
disabledCount,
maxPeriod,
@ -76,15 +83,23 @@ export async function getStatus(
projectMonitorsCount,
} = await processMonitors(allMonitors, server, soClient, syntheticsMonitorClient);
// Account for locations filter
const queryLocationsArray =
queryLocations && !Array.isArray(queryLocations) ? [queryLocations] : queryLocations;
const listOfLocationAfterFilter = queryLocationsArray
? intersection(listOfLocations, queryLocationsArray)
: listOfLocations;
const { up, down, pending, upConfigs, downConfigs } = await queryMonitorStatus(
uptimeEsClient,
listOfLocations,
listOfLocationAfterFilter,
{ from: maxPeriod, to: 'now' },
enabledIds,
monitorLocationMap
);
return {
allIds,
allMonitorsCount: allMonitors.length,
disabledMonitorsCount,
projectMonitorsCount,

View file

@ -27,12 +27,15 @@ export const getAllMonitors = async ({
soClient,
search,
fields,
filter,
sortField,
sortOrder,
searchFields,
}: {
soClient: SavedObjectsClientContract;
search?: string;
} & Pick<SavedObjectsFindOptions, 'sortField' | 'sortOrder' | 'fields'>) => {
filter?: string;
} & Pick<SavedObjectsFindOptions, 'sortField' | 'sortOrder' | 'fields' | 'searchFields'>) => {
const finder = soClient.createPointInTimeFinder({
type: syntheticsMonitorType,
perPage: 1000,
@ -40,6 +43,8 @@ export const getAllMonitors = async ({
sortField,
sortOrder,
fields,
filter,
searchFields,
});
const hits: Array<SavedObjectsFindResult<EncryptedSyntheticsMonitor>> = [];