[Synthetics] Remove Saved Object Client usage from use_recently_viewed_monitors hook (#159033)

Relates to #https://github.com/elastic/kibana/issues/153399

## Summary

Removes the deprecated Saved Object Client dependency from
`useRecentlyViewedMonitors` hook.

-
[`getAllSyntheticsMonitorRoute`](https://github.com/elastic/kibana/pull/159033/files#diff-ee80010902693c9786b3043c6ec4e2f42a62fc33e55f9c7666bbf7034fe6d165R34
) will now accept additional filter `monitorQueryIds` which will only
query the saved objects for the provided Monitor Query IDs.
- The PR also refactors the
[`useMonitorName`](https://github.com/elastic/kibana/pull/159033/files#diff-c066d4869d55ec490d753a0febd925eeb12778836edd3603481128bdc51142feR30)
to fix a bug with name duplication case on Monitor Add/Edit where the
form was allowing the duplicate name to be saved. Also, since
`useMonitorName` was using
[`useMonitorList`](https://github.com/elastic/kibana/pull/159033/files#diff-f4f40e819338bf8c22f9c060e4941576f9659d3c7a206130884554b776d4b606R22)
which wasn't returning the correct "loading" state for a query search
due to `quietFetchMonitorListAction`, `useMonitorName` is refactored to
utilize the `fetchMonitorManagementList` to have its own data and
"loading" flag. The `query` parameter from `useMonitorList` is removed
as `useMonitorName` was the only consumer.
This commit is contained in:
Abdul Wahab Zahid 2023-06-07 19:35:12 +02:00 committed by GitHub
parent 6383f45ed0
commit ff5b71c10d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 453 additions and 224 deletions

View file

@ -354,12 +354,6 @@ export const HeartbeatConfigCodec = t.intersection([
}),
]);
export const EncryptedSyntheticsMonitorWithIdCodec = t.intersection([
EncryptedSyntheticsMonitorCodec,
t.interface({ id: t.string }),
]);
// TODO: Remove EncryptedSyntheticsMonitorWithIdCodec (as well as SyntheticsMonitorWithIdCodec if possible) along with respective TypeScript types in favor of EncryptedSyntheticsSavedMonitorCodec
export const EncryptedSyntheticsSavedMonitorCodec = t.intersection([
EncryptedSyntheticsMonitorCodec,
t.interface({ id: t.string, updated_at: t.string, created_at: t.string }),
@ -367,10 +361,6 @@ export const EncryptedSyntheticsSavedMonitorCodec = t.intersection([
export type SyntheticsMonitorWithId = t.TypeOf<typeof SyntheticsMonitorWithIdCodec>;
export type EncryptedSyntheticsMonitorWithId = t.TypeOf<
typeof EncryptedSyntheticsMonitorWithIdCodec
>;
export type EncryptedSyntheticsSavedMonitor = t.TypeOf<typeof EncryptedSyntheticsSavedMonitorCodec>;
export type HeartbeatConfig = t.TypeOf<typeof HeartbeatConfigCodec>;

View file

@ -19,6 +19,7 @@ export const FetchMonitorManagementListQueryArgsCodec = t.partial({
monitorTypes: t.array(t.string),
projects: t.array(t.string),
schedules: t.array(t.string),
monitorQueryIds: t.array(t.string),
});
export type FetchMonitorManagementListQueryArgs = t.TypeOf<

View file

@ -9,6 +9,7 @@ import React from 'react';
import { mockGlobals } from '../../utils/testing';
import { render } from '../../utils/testing/rtl_helpers';
import { MonitorEditPage } from './monitor_edit_page';
import { useMonitorName } from '../../hooks/use_monitor_name';
import { ConfigKey } from '../../../../../common/runtime_types';
import * as observabilitySharedPublic from '@kbn/observability-shared-plugin/public';
@ -21,6 +22,11 @@ mockGlobals();
jest.mock('@kbn/observability-shared-plugin/public');
jest.mock('../../hooks/use_monitor_name', () => ({
...jest.requireActual('../../hooks/use_monitor_name'),
useMonitorName: jest.fn().mockReturnValue({ nameAlreadyExists: false }),
}));
jest.mock('@kbn/kibana-react-plugin/public', () => {
const original = jest.requireActual('@kbn/kibana-react-plugin/public');
return {
@ -191,4 +197,49 @@ describe('MonitorEditPage', () => {
// error
expect(getByText('Unable to load monitor configuration')).toBeInTheDocument();
});
it.each([true, false])(
'shows duplicate error when "nameAlreadyExists" is %s',
(nameAlreadyExists) => {
(useMonitorName as jest.Mock).mockReturnValue({ nameAlreadyExists });
jest.spyOn(observabilitySharedPublic, 'useFetcher').mockReturnValue({
status: FETCH_STATUS.SUCCESS,
data: {
attributes: {
[ConfigKey.MONITOR_SOURCE_TYPE]: 'ui',
[ConfigKey.FORM_MONITOR_TYPE]: 'multistep',
[ConfigKey.LOCATIONS]: [],
[ConfigKey.THROTTLING_CONFIG]: PROFILES_MAP[PROFILE_VALUES_ENUM.DEFAULT],
},
},
refetch: () => null,
loading: false,
});
const { getByText, queryByText } = render(<MonitorEditPage />, {
state: {
serviceLocations: {
locations: [
{
id: 'us_central',
label: 'Us Central',
},
{
id: 'us_east',
label: 'US East',
},
],
locationsLoaded: true,
loading: false,
},
},
});
if (nameAlreadyExists) {
expect(getByText('Monitor name already exists')).toBeInTheDocument();
} else {
expect(queryByText('Monitor name already exists')).not.toBeInTheDocument();
}
}
);
});

View file

@ -28,7 +28,7 @@ type MonitorOption = EuiSelectableOption & {
export const MonitorSearchableList = ({ closePopover }: { closePopover: () => void }) => {
const history = useHistory();
const recentlyViewed = useRecentlyViewedMonitors();
const { recentMonitorOptions, loading: recentMonitorsLoading } = useRecentlyViewedMonitors();
const [options, setOptions] = useState<MonitorOption[]>([]);
const [searchValue, setSearchValue] = useState('');
@ -37,13 +37,13 @@ export const MonitorSearchableList = ({ closePopover }: { closePopover: () => vo
const { basePath } = useSyntheticsSettingsContext();
const { values, loading } = useMonitorName({ search: searchValue });
const { values, loading: searchLoading } = useMonitorName({ search: searchValue });
useEffect(() => {
const newOptions: MonitorOption[] = [];
if (recentlyViewed.length > 0 && !searchValue) {
if (recentMonitorOptions.length > 0 && !searchValue) {
const otherMonitors = values.filter((value) =>
recentlyViewed.every((recent) => recent.key !== value.key)
recentMonitorOptions.every((recent) => recent.key !== value.key)
) as MonitorOption[];
if (otherMonitors.length > 0) {
@ -55,11 +55,11 @@ export const MonitorSearchableList = ({ closePopover }: { closePopover: () => vo
});
}
setOptions([...recentlyViewed, ...newOptions, ...otherMonitors]);
setOptions([...recentMonitorOptions, ...newOptions, ...otherMonitors]);
} else {
setOptions(values);
}
}, [recentlyViewed, searchValue, values]);
}, [recentMonitorOptions, searchValue, values]);
const getLocationId = (option: MonitorOption) => {
if (option.locationIds?.includes(selectedLocation?.id ?? '')) {
@ -71,7 +71,7 @@ export const MonitorSearchableList = ({ closePopover }: { closePopover: () => vo
return (
<EuiSelectable<MonitorOption>
searchable
isLoading={loading}
isLoading={searchLoading || recentMonitorsLoading}
searchProps={{
placeholder: PLACEHOLDER,
compressed: true,
@ -108,7 +108,7 @@ export const MonitorSearchableList = ({ closePopover }: { closePopover: () => vo
{(list, search) => (
<div style={{ width: 280 }}>
<EuiPopoverTitle paddingSize="s">
{options.length > 0 || searchValue ? (
{options.length > 0 || searchValue || searchLoading || recentMonitorsLoading ? (
search
) : (
<EuiText color="subdued" size="s" className="eui-textCenter">

View file

@ -4,34 +4,39 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { waitFor } from '@testing-library/react';
import type { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import * as localStorageModule from 'react-use/lib/useLocalStorage';
import { fetchMonitorManagementList } from '../../../state';
import * as useMonitorQueryModule from '../hooks/use_monitor_query_id';
import { useRecentlyViewedMonitors } from './use_recently_viewed_monitors';
import { mockCore, WrappedHelper } from '../../../utils/testing';
import { syntheticsMonitorType } from '../../../../../../common/types/saved_objects';
import { WrappedHelper } from '../../../utils/testing';
import { MONITOR_ROUTE } from '../../../../../../common/constants';
const resultData = {
resolved_objects: [
{
saved_object: {
id: 'c9322230-2a11-11ed-962b-d3e7eeedf9d1',
jest.mock('../../../state', () => ({
...jest.requireActual('../../../state'),
fetchMonitorManagementList: jest.fn(),
}));
attributes: {
name: 'Test Monitor',
locations: [],
},
},
},
],
};
jest.mock('@kbn/observability-shared-plugin/public', () => ({
...jest.requireActual('@kbn/observability-shared-plugin/public'),
useFetcher: jest.fn(),
}));
describe('useRecentlyViewedMonitors', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
it('returns expected result', () => {
const WrapperWithState = ({ children }: { children: React.ReactElement }) => {
return (
@ -41,48 +46,83 @@ describe('useRecentlyViewedMonitors', () => {
);
};
jest.spyOn(useMonitorQueryModule, 'useMonitorQueryId').mockImplementation(() => '1');
(useFetcher as jest.Mock).mockImplementation((callback) => {
callback();
return { loading: false, status: 'success' as FETCH_STATUS.SUCCESS, refetch: () => {} };
});
const { result } = renderHook(() => useRecentlyViewedMonitors(), { wrapper: WrapperWithState });
expect(result.current).toEqual([]);
expect(result.current).toEqual({ loading: false, recentMonitorOptions: [] });
});
it('returns the result when found', async () => {
const core = mockCore();
it('fetches the persisted ids and persists the updated information', async () => {
const currentMonitorQueryId = 'id-01';
const monitorQueryId3 = 'persisted-id-03';
let persistedIds = ['persisted-id-02', monitorQueryId3];
const setPersistedIdsMock = jest.fn().mockImplementation((ids: string[]) => {
persistedIds = ids;
});
core.savedObjects!.client.bulkResolve = jest.fn().mockResolvedValue(resultData);
jest
.spyOn(useMonitorQueryModule, 'useMonitorQueryId')
.mockImplementation(() => currentMonitorQueryId);
jest
.spyOn(localStorageModule, 'default')
.mockImplementation(() => [persistedIds, setPersistedIdsMock, () => {}]);
(useFetcher as jest.Mock).mockImplementation((callback) => {
callback();
return { loading: false, status: 'success' as FETCH_STATUS.SUCCESS, refetch: () => {} };
});
// Return only 'persisted-id-03' to mark 'persisted-id-02' as a deleted monitor
const fetchedMonitor = {
id: 'uuid-monitor-03',
attributes: {
id: monitorQueryId3,
name: 'Monitor 03',
locations: [],
},
};
(fetchMonitorManagementList as jest.Mock).mockReturnValue({
monitors: [fetchedMonitor],
});
const WrapperWithState = ({ children }: { children: React.ReactElement }) => {
return (
<WrappedHelper core={core} url="/monitor/1" path={MONITOR_ROUTE}>
<WrappedHelper url="/monitor/1" path={MONITOR_ROUTE}>
{children}
</WrappedHelper>
);
};
const { result, waitForValueToChange, rerender } = renderHook(
() => useRecentlyViewedMonitors(),
{
wrapper: WrapperWithState,
}
);
await waitForValueToChange(() => persistedIds);
const { result, waitForNextUpdate } = renderHook(() => useRecentlyViewedMonitors(), {
wrapper: WrapperWithState,
});
expect(result.current).toEqual([]);
// Sets the current monitor as well as updated information
expect(setPersistedIdsMock).toHaveBeenCalledWith([currentMonitorQueryId, monitorQueryId3]);
expect(core.savedObjects?.client.bulkResolve).toHaveBeenCalledTimes(1);
expect(core.savedObjects?.client.bulkResolve).toHaveBeenLastCalledWith([
{ id: '1', type: syntheticsMonitorType },
]);
await waitForNextUpdate();
await waitFor(() => {
expect(result.current).toEqual([
{
isGroupLabel: true,
key: 'recently_viewed',
label: 'Recently viewed',
},
{
key: 'c9322230-2a11-11ed-962b-d3e7eeedf9d1',
label: 'Test Monitor',
locationIds: [],
},
]);
});
rerender();
const expectedOptions = [
{
isGroupLabel: true,
key: 'recently_viewed',
label: 'Recently viewed',
},
{
isGroupLabel: false,
key: fetchedMonitor.id,
label: fetchedMonitor.attributes.name,
locationIds: fetchedMonitor.attributes.locations,
monitorQueryId: monitorQueryId3,
},
];
expect(result.current).toEqual({ loading: false, recentMonitorOptions: expectedOptions });
});
});

View file

@ -5,75 +5,109 @@
* 2.0.
*/
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { useParams } from 'react-router-dom';
import { useEffect, useMemo } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import { MonitorFields } from '../../../../../../common/runtime_types';
import { syntheticsMonitorType } from '../../../../../../common/types/saved_objects';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { fetchMonitorManagementList, getMonitorListPageStateWithDefaults } from '../../../state';
import { useMonitorQueryId } from '../hooks/use_monitor_query_id';
const HISTORY_LENGTH = 5;
interface RecentMonitorSelectableOption {
key: string;
monitorQueryId: string;
label: string;
locationIds: string[];
isGroupLabel: boolean;
}
export const useRecentlyViewedMonitors = () => {
const [recentlyViewed, setRecentlyViewed] = useLocalStorage<string[]>(
'xpack.synthetics.recentlyViewedMonitors',
[]
);
const { monitorId } = useParams<{ monitorId: string }>();
const [recentlyViewedMonitorQueryIds, setRecentlyViewedMonitorQueryIds] = useLocalStorage<
string[]
>('xpack.synthetics.recentlyViewedMonitors', []);
const fetchedMonitorsRef = useRef<RecentMonitorSelectableOption[]>([]);
const monitorQueryId = useMonitorQueryId();
const { savedObjects } = useKibana().services;
const fetchedMonitorQueryIdsSnap = JSON.stringify(
[...fetchedMonitorsRef.current.map(({ key }) => key)].sort()
);
const updateRecentlyViewed = useCallback(() => {
const updatedIdsToPersist = fetchedMonitorsRef.current.length
? fetchedMonitorsRef.current.map(({ monitorQueryId: id }) => id)
: recentlyViewedMonitorQueryIds ?? [];
if (monitorQueryId) {
setRecentlyViewedMonitorQueryIds(
[monitorQueryId, ...updatedIdsToPersist]
.filter((id, index, arr) => arr.indexOf(id) === index)
.slice(0, HISTORY_LENGTH)
);
}
// Exclude `recentlyViewedMonitorQueryIds`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setRecentlyViewedMonitorQueryIds, monitorQueryId, fetchedMonitorQueryIdsSnap]);
useEffect(() => {
const newRecentlyViewed = [
...new Set([...(monitorId ? [monitorId] : []), ...(recentlyViewed ?? [])]),
].slice(0, 5);
updateRecentlyViewed();
}, [updateRecentlyViewed, monitorQueryId]);
if (
newRecentlyViewed?.[0] !== recentlyViewed?.[0] ||
newRecentlyViewed.length !== recentlyViewed?.length
) {
setRecentlyViewed(newRecentlyViewed);
}
}, [monitorId, recentlyViewed, setRecentlyViewed]);
const { data } = useFetcher(async () => {
const monitorsList = recentlyViewed ?? [];
const { resolved_objects: monitorObjects } = await savedObjects!.client.bulkResolve(
monitorsList.map((monId) => ({
type: syntheticsMonitorType,
id: monId,
}))
const { loading } = useFetcher(async () => {
const monitorQueryIdsToFetch = (recentlyViewedMonitorQueryIds ?? []).filter(
(id) => id !== monitorQueryId
);
if (
monitorQueryId &&
monitorQueryIdsToFetch.length &&
JSON.stringify([...monitorQueryIdsToFetch].sort()) !== fetchedMonitorQueryIdsSnap
) {
const fetchedResult = await fetchMonitorManagementList(
getMonitorListPageStateWithDefaults({
pageSize: HISTORY_LENGTH,
monitorQueryIds: monitorQueryIdsToFetch,
})
);
const missingMonitors = monitorObjects
.filter((mon) => mon.saved_object.error?.statusCode === 404)
.map((mon) => mon.saved_object.id);
if (fetchedResult?.monitors?.length) {
const persistedOrderHash = monitorQueryIdsToFetch.reduce(
(acc, cur, index) => ({ ...acc, [cur]: index }),
{} as Record<string, number>
);
if (missingMonitors.length > 0) {
setRecentlyViewed(monitorsList.filter((monId) => !missingMonitors.includes(monId)));
// Reorder fetched monitors as per the persisted order
const fetchedMonitorsInPersistedOrder = [...fetchedResult?.monitors].sort(
(a, b) => persistedOrderHash[a.attributes.id] - persistedOrderHash[b.attributes.id]
);
fetchedMonitorsRef.current = fetchedMonitorsInPersistedOrder.map((mon) => {
return {
key: mon.id,
monitorQueryId: mon.attributes.id,
label: mon.attributes.name,
locationIds: (mon.attributes.locations ?? []).map(({ id }) => id),
isGroupLabel: false,
};
});
updateRecentlyViewed();
}
}
}, [monitorQueryId]);
return monitorObjects
.filter(
({ saved_object: monitor }) => Boolean(monitor.attributes) && monitor.id !== monitorId
)
.map(({ saved_object: monitor }) => ({
key: monitor.id,
label: (monitor.attributes as MonitorFields).name,
locationIds: (monitor.attributes as MonitorFields).locations.map((location) => location.id),
}));
}, [monitorId, recentlyViewed]);
return useMemo(() => {
if ((data ?? []).length === 0) {
return [];
}
return [
{ key: 'recently_viewed', label: RECENTLY_VIEWED, isGroupLabel: true },
...(data ?? []),
];
}, [data]);
return useMemo(
() => ({
loading,
recentMonitorOptions: fetchedMonitorsRef.current.length
? [
{ key: 'recently_viewed', label: RECENTLY_VIEWED, isGroupLabel: true },
...fetchedMonitorsRef.current,
]
: [],
}),
// Make it also depend on `fetchedMonitorQueryIdsSnap`
// eslint-disable-next-line react-hooks/exhaustive-deps
[loading, fetchedMonitorQueryIdsSnap]
);
};
const RECENTLY_VIEWED = i18n.translate('xpack.synthetics.monitorSummary.recentlyViewed', {

View file

@ -19,7 +19,7 @@ import {
} from '../../../state';
import { useSyntheticsRefreshContext } from '../../../contexts';
export function useMonitorList(query?: string) {
export function useMonitorList() {
const dispatch = useDispatch();
const isInitialMount = useRef(true);
@ -40,11 +40,11 @@ export function useMonitorList(query?: string) {
// Periodically refresh
useEffect(() => {
if (!isInitialMount.current) {
dispatch(quietFetchMonitorListAction({ ...pageState, query }));
dispatch(quietFetchMonitorListAction({ ...pageState }));
}
// specifically only want to run this on refreshInterval change on query change
// specifically only want to run this on refreshInterval change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastRefresh, query]);
}, [lastRefresh]);
// On initial mount, load the page
useDebounce(

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { WrappedHelper } from '../utils/testing/rtl_helpers';
import { renderHook } from '@testing-library/react-hooks';
import { fetchMonitorManagementList } from '../state';
import { useMonitorName } from './use_monitor_name';
jest.mock('react-router-dom', () => ({
@ -14,66 +14,62 @@ jest.mock('react-router-dom', () => ({
useParams: jest.fn().mockReturnValue({ monitorId: '12345' }),
}));
describe('useMonitorName', () => {
const Wrapper = ({ children }: { children: React.ReactElement }) => {
return (
<WrappedHelper
state={{
monitorList: {
error: null,
loading: true,
loaded: false,
monitorUpsertStatuses: {},
data: {
absoluteTotal: 1,
perPage: 5,
page: 1,
total: 1,
monitors: [
{
attributes: {
name: 'Test monitor name',
config_id: '12345',
locations: [
{
id: 'us_central_qa',
},
],
},
},
{
attributes: {
name: 'Test monitor name 2',
config_id: '12346',
locations: [
{
id: 'us_central_qa',
},
],
},
},
],
syncErrors: [],
},
pageState: {
pageIndex: 1,
pageSize: 10,
sortOrder: 'asc',
sortField: `name.keyword`,
},
},
}}
>
{children}
</WrappedHelper>
);
};
jest.mock('../state', () => ({
...jest.requireActual('../state'),
fetchMonitorManagementList: jest.fn(),
}));
it('returns expected results', () => {
const { result } = renderHook(() => useMonitorName({}), { wrapper: Wrapper });
describe('useMonitorName', () => {
const testMonitors = [
{
attributes: {
name: 'Test monitor name',
config_id: '12345',
locations: [
{
id: 'us_central_qa',
},
],
},
},
{
attributes: {
name: 'Test monitor name 2',
config_id: '12346',
locations: [
{
id: 'us_central_qa',
},
],
},
},
];
beforeEach(() => {
jest.clearAllMocks();
(fetchMonitorManagementList as jest.Mock).mockResolvedValue({
monitors: testMonitors,
});
});
afterAll(() => {
jest.restoreAllMocks();
});
it('returns expected initial and after load state', async () => {
const { result, waitForValueToChange } = renderHook(() => useMonitorName({}));
expect(result.current).toStrictEqual({
loading: true,
values: [],
nameAlreadyExists: false,
});
await waitForValueToChange(() => result.current.values);
expect(result.current).toStrictEqual({
loading: false,
values: [
{
key: '12346',
@ -82,19 +78,18 @@ describe('useMonitorName', () => {
},
],
nameAlreadyExists: false,
validName: '',
});
});
it('returns expected results after data', async () => {
const { result } = renderHook(() => useMonitorName({ search: 'Test monitor name 2' }), {
wrapper: Wrapper,
});
it('returns correct "nameAlreadyExists" when name matches', async () => {
const { result, waitForValueToChange } = renderHook(() =>
useMonitorName({ search: 'Test monitor name 2' })
);
await waitForValueToChange(() => result.current.values); // Wait until data has been loaded
expect(result.current).toStrictEqual({
loading: true,
nameAlreadyExists: false,
validName: 'Test monitor name 2',
loading: false,
nameAlreadyExists: true,
values: [
{
key: '12346',
@ -106,14 +101,14 @@ describe('useMonitorName', () => {
});
it('returns expected results after data while editing monitor', async () => {
const { result } = renderHook(() => useMonitorName({ search: 'Test monitor name' }), {
wrapper: Wrapper,
});
const { result, waitForValueToChange } = renderHook(() =>
useMonitorName({ search: 'Test monitor name' })
);
await waitForValueToChange(() => result.current.values); // Wait until data has been loaded
expect(result.current).toStrictEqual({
loading: true,
nameAlreadyExists: false,
validName: 'Test monitor name',
loading: false,
nameAlreadyExists: false, // Should be `false` for the currently editing monitor,
values: [
{
key: '12346',

View file

@ -4,33 +4,46 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useMonitorList } from '../components/monitors_page/hooks/use_monitor_list';
import { useDebounce } from 'react-use';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import { fetchMonitorManagementList, getMonitorListPageStateWithDefaults } from '../state';
export const useMonitorName = ({ search = '' }: { search?: string }) => {
const { monitorId } = useParams<{ monitorId: string }>();
const { syntheticsMonitors, loading } = useMonitorList(search);
const [debouncedSearch, setDebouncedSearch] = useState<string>(search);
useDebounce(() => setDebouncedSearch(search), 500, [search]);
const { loading, data: monitors } = useFetcher(async () => {
const fetchedResult = await fetchMonitorManagementList(
getMonitorListPageStateWithDefaults({ query: debouncedSearch })
);
return (fetchedResult?.monitors ?? []).map((monitor) => ({
label: monitor.attributes.name,
key: monitor.attributes.config_id,
locationIds: monitor.attributes.locations.map((location) => location.id),
}));
}, [debouncedSearch]);
return useMemo(() => {
const searchPattern = search.replace(/\s/g, '').toLowerCase();
const nameAlreadyExists = Boolean(
syntheticsMonitors?.some(
(monitor) => monitor.name.trim().toLowerCase() === search && monitorId !== monitor.config_id
(monitors ?? []).some(
(monitor) =>
monitorId !== monitor.key &&
monitor.label.replace(/\s/g, '').toLowerCase() === searchPattern
)
);
const values = syntheticsMonitors.map((monitor) => ({
label: monitor.name as string,
key: monitor.config_id,
locationIds: monitor.locations.map((location) => location.id),
}));
return {
loading,
loading: loading || debouncedSearch !== search, // Also keep busy while waiting for debounce
nameAlreadyExists,
validName: nameAlreadyExists ? '' : search,
values: values.filter((val) => val.key !== monitorId),
values: (monitors ?? []).filter((val) => val.key !== monitorId),
};
}, [loading, monitorId, syntheticsMonitors, search]);
}, [loading, monitorId, monitors, search, debouncedSearch]);
};

View file

@ -35,6 +35,7 @@ function toMonitorManagementListQueryArgs(
monitorTypes: pageState.monitorTypes,
projects: pageState.projects,
schedules: pageState.schedules,
monitorQueryIds: pageState.monitorQueryIds,
searchFields: [],
};
}

View file

@ -0,0 +1,23 @@
/*
* 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 { ConfigKey } from '../../../../../common/constants/monitor_management';
import { MonitorListPageState } from './models';
const DEFAULT_PAGE_STATE: MonitorListPageState = {
pageIndex: 0,
pageSize: 10,
sortOrder: 'asc',
sortField: `${ConfigKey.NAME}.keyword`,
};
export function getMonitorListPageStateWithDefaults(override?: Partial<MonitorListPageState>) {
return {
...DEFAULT_PAGE_STATE,
...(override ?? {}),
};
}

View file

@ -10,7 +10,6 @@ import { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import { SavedObject } from '@kbn/core-saved-objects-common';
import {
ConfigKey,
MonitorManagementListResult,
SyntheticsMonitor,
MonitorFiltersResult,
@ -19,6 +18,8 @@ import {
import { IHttpSerializedFetchError } from '../utils/http_error';
import { MonitorListPageState } from './models';
import { getMonitorListPageStateWithDefaults } from './helpers';
import {
cleanMonitorListState,
clearMonitorUpsertStatus,
@ -47,12 +48,7 @@ export interface MonitorListState {
const initialState: MonitorListState = {
data: { page: 1, perPage: 10, total: null, monitors: [], syncErrors: [], absoluteTotal: 0 },
monitorUpsertStatuses: {},
pageState: {
pageIndex: 0,
pageSize: 10,
sortOrder: 'asc',
sortField: `${ConfigKey.NAME}.keyword`,
},
pageState: getMonitorListPageStateWithDefaults(),
loading: false,
loaded: false,
error: null,
@ -139,4 +135,5 @@ export * from './models';
export * from './actions';
export * from './effects';
export * from './selectors';
export * from './helpers';
export { fetchDeleteMonitor, fetchUpsertMonitor, fetchCreateMonitor } from './api';

View file

@ -23,6 +23,7 @@ export interface MonitorFilterState {
projects?: string[];
schedules?: string[];
locations?: string[];
monitorQueryIds?: string[]; // Monitor Query IDs
}
export interface MonitorListPageState extends MonitorFilterState {

View file

@ -10,7 +10,10 @@ import { invert } from 'lodash';
import { DataStream, ServiceLocations } from '../../../../../common/runtime_types';
import { MonitorFilterState } from '../../state';
export type SyntheticsMonitorFilterField = keyof Omit<MonitorFilterState, 'query'>;
export type SyntheticsMonitorFilterField = keyof Omit<
MonitorFilterState,
'query' | 'monitorQueryIds'
>;
export interface LabelWithCountValue {
label: string;

View file

@ -31,6 +31,7 @@ export const QuerySchema = schema.object({
schedules: StringOrArraySchema,
status: StringOrArraySchema,
searchAfter: schema.maybe(schema.arrayOf(schema.string())),
monitorQueryIds: StringOrArraySchema,
});
export type MonitorsQuery = TypeOf<typeof QuerySchema>;
@ -76,6 +77,7 @@ export const getMonitors = async (
searchAfter,
projects,
schedules,
monitorQueryIds,
} = context.request.query;
const filterStr = await getMonitorFilters({
@ -85,10 +87,11 @@ export const getMonitors = async (
locations,
projects,
schedules,
monitorQueryIds,
context,
});
return context.savedObjectsClient.find({
const findParams = {
type: syntheticsMonitorType,
perPage,
page,
@ -99,7 +102,9 @@ export const getMonitors = async (
filter: filterStr,
searchAfter,
fields,
});
};
return context.savedObjectsClient.find(findParams);
};
export const getMonitorFilters = async ({
@ -109,6 +114,7 @@ export const getMonitorFilters = async ({
projects,
monitorTypes,
schedules,
monitorQueryIds,
context,
}: {
filter?: string;
@ -117,6 +123,7 @@ export const getMonitorFilters = async ({
locations?: string | string[];
projects?: string | string[];
schedules?: string | string[];
monitorQueryIds?: string | string[];
context: RouteContext;
}) => {
const locationFilter = await parseLocationFilter(context, locations);
@ -128,6 +135,7 @@ export const getMonitorFilters = async ({
getKqlFilter({ field: 'type', values: monitorTypes }),
getKqlFilter({ field: 'locations.id', values: locationFilter }),
getKqlFilter({ field: 'schedule.number', values: schedules }),
getKqlFilter({ field: 'id', values: monitorQueryIds }),
]
.filter((f) => !!f)
.join(' AND ');
@ -187,8 +195,17 @@ export const findLocationItem = (query: string, locations: ServiceLocations) =>
* @param monitorQuery { MonitorsQuery }
*/
export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => {
const { query, tags, monitorTypes, locations, status, filter, projects, schedules } =
monitorQuery;
const {
query,
tags,
monitorTypes,
locations,
status,
filter,
projects,
schedules,
monitorQueryIds,
} = monitorQuery;
return (
!!query ||
@ -198,7 +215,8 @@ export const isMonitorsQueryFiltered = (monitorQuery: MonitorsQuery) => {
!!tags?.length ||
!!status?.length ||
!!projects?.length ||
!!schedules?.length
!!schedules?.length ||
!!monitorQueryIds?.length
);
};

View file

@ -83,6 +83,68 @@ export default function ({ getService }: FtrProviderContext) {
expect(firstPageResp.body.monitors[0].id).not.eql(secondPageResp.body.monitors[0].id);
});
it('with single monitorQueryId filter', async () => {
const [_, { id: id2 }] = await Promise.all(monitors.map(saveMonitor));
const resp = await supertest
.get(`${API_URLS.SYNTHETICS_MONITORS}?page=1&perPage=10&monitorQueryIds=${id2}`)
.expect(200);
const resultMonitorIds = resp.body.monitors.map(
({ attributes: { id } }: { attributes: Partial<MonitorFields> }) => id
);
expect(resultMonitorIds.length).eql(1);
expect(resultMonitorIds).eql([id2]);
});
it('with multiple monitorQueryId filter', async () => {
const [_, { id: id2 }, { id: id3 }] = await Promise.all(monitors.map(saveMonitor));
const resp = await supertest
.get(
`${API_URLS.SYNTHETICS_MONITORS}?page=1&perPage=10&sortField=name.keyword&sortOrder=asc&monitorQueryIds=${id2}&monitorQueryIds=${id3}`
)
.expect(200);
const resultMonitorIds = resp.body.monitors.map(
({ attributes: { id } }: { attributes: Partial<MonitorFields> }) => id
);
expect(resultMonitorIds.length).eql(2);
expect(resultMonitorIds).eql([id2, id3]);
});
it('monitorQueryId respects custom_heartbeat_id while filtering', async () => {
const customHeartbeatId0 = 'custom-heartbeat-id-test-01';
const customHeartbeatId1 = 'custom-heartbeat-id-test-02';
await Promise.all(
[
{
...monitors[0],
[ConfigKey.CUSTOM_HEARTBEAT_ID]: customHeartbeatId0,
[ConfigKey.NAME]: `NAME-${customHeartbeatId0}`,
},
{
...monitors[1],
[ConfigKey.CUSTOM_HEARTBEAT_ID]: customHeartbeatId1,
[ConfigKey.NAME]: `NAME-${customHeartbeatId1}`,
},
].map(saveMonitor)
);
const resp = await supertest
.get(
`${API_URLS.SYNTHETICS_MONITORS}?page=1&perPage=10&sortField=name.keyword&sortOrder=asc&monitorQueryIds=${customHeartbeatId0}&monitorQueryIds=${customHeartbeatId1}`
)
.expect(200);
const resultMonitorIds = resp.body.monitors
.map(({ attributes: { id } }: { attributes: Partial<MonitorFields> }) => id)
.filter((id: string, index: number, arr: string[]) => arr.indexOf(id) === index); // Filter only unique
expect(resultMonitorIds.length).eql(2);
expect(resultMonitorIds).eql([customHeartbeatId0, customHeartbeatId1]);
});
});
describe('get one monitor', () => {