[Synthetics] Overview - add search bar (#142513)

## Summary

Resolves https://github.com/elastic/kibana/issues/142523
Relates to https://github.com/elastic/kibana/issues/135160

Added Overview search bar and quick filters for Up, Down, or Disabled
status.

<img width="1375" alt="Screen Shot 2022-11-07 at 1 59 42 PM"
src="https://user-images.githubusercontent.com/11356435/200450640-1586d452-0e57-4477-886c-e60d0dd3d8d1.png">
<img width="1376" alt="Screen Shot 2022-11-07 at 1 59 10 PM"
src="https://user-images.githubusercontent.com/11356435/200450644-ee2b1415-8cc3-4c44-9661-c6246390b5e9.png">
<img width="1387" alt="Screen Shot 2022-11-07 at 1 59 18 PM"
src="https://user-images.githubusercontent.com/11356435/200450646-e3eed782-a143-4ad0-adbb-ea5f6b6e1d71.png">
<img width="1382" alt="Screen Shot 2022-11-07 at 1 59 26 PM"
src="https://user-images.githubusercontent.com/11356435/200450647-821c8376-86dd-449d-919c-897d7946ac8c.png">

### Testing
1. Create a few different monitors, ensuring a mix of up and down
monitors
2. Search for the monitor via the search bar. Ensure that the monitor
appears
3. Filter by up and down status. Ensure the correct monitors appear
4. Sort the monitors while using the up and down filters. Ensure the
monitors are filtered by status and sorted at the same time
5. Enter a search that matches no monitors. Confirm the no monitors
found UX appears

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dominique Clarke 2022-11-08 21:28:25 -05:00 committed by GitHub
parent 62425648c7
commit 2f3313371b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1019 additions and 182 deletions

View file

@ -130,7 +130,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"siem-ui-timeline-pinned-event": "e2697b38751506c7fce6e8b7207a830483dc4283",
"space": "c4a0acce1bd4b9cce85154f2a350624a53111c59",
"spaces-usage-stats": "922d3235bbf519e3fb3b260e27248b1df8249b79",
"synthetics-monitor": "0c62bf304aebd2134b20627519713819da896eb1",
"synthetics-monitor": "30f1cd04016a37095de60554cbf7fff89aaad177",
"synthetics-privates-locations": "dd00385f4a27ef062c3e57312eeb3799872fa4af",
"tag": "39413f4578cc2128c9a0fda97d0acd1c8862c47a",
"task": "ef53d0f070bd54957b8fe22fae3b1ff208913f76",

View file

@ -23,6 +23,18 @@ export type FetchMonitorManagementListQueryArgs = t.TypeOf<
typeof FetchMonitorManagementListQueryArgsCodec
>;
export const FetchMonitorOverviewQueryArgsCodec = t.partial({
query: t.string,
searchFields: t.array(t.string),
tags: t.array(t.string),
locations: t.array(t.string),
monitorType: t.array(t.string),
sortField: t.string,
sortOrder: t.string,
});
export type FetchMonitorOverviewQueryArgs = t.TypeOf<typeof FetchMonitorOverviewQueryArgsCodec>;
export const MonitorManagementEnablementResultCodec = t.type({
isEnabled: t.boolean,
canEnable: t.boolean,

View file

@ -38,6 +38,7 @@ journey('Test Monitor Detail Flyout', async ({ page, params }) => {
step('open overview flyout', async () => {
await syntheticsApp.navigateToOverview();
await syntheticsApp.assertText({ text: monitorName });
await page.click(`[data-test-subj="${monitorName}-metric-item"]`);
const flyoutHeader = await page.waitForSelector('.euiFlyoutHeader');
expect(await flyoutHeader.innerText()).toContain(monitorName);

View file

@ -11,4 +11,5 @@ export * from './getting_started.journey';
export * from './monitor_selector.journey';
export * from './overview_sorting.journey';
// TODO: Fix this test
// export * from './overview_scrolling.journey';
export * from './overview_scrolling.journey';
export * from './overview_search.journey';

View file

@ -48,7 +48,7 @@ journey('Overview Scrolling', async ({ page, params }) => {
await page.waitForSelector(`text="test monitor 0"`);
let count = await gridItems.count();
expect(count).toBe(32);
expect(count <= 32).toBe(true);
while (!showingAllMonitorsNode) {
await page.mouse.wheel(0, 100);

View file

@ -0,0 +1,182 @@
/*
* 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 { before, expect, journey, step } from '@elastic/synthetics';
import {
addTestMonitor,
cleanTestMonitors,
enableMonitorManagedViaApi,
} from './services/add_monitor';
import { syntheticsAppPageProvider } from '../../page_objects/synthetics_app';
journey('Overview Search', async ({ page, params }) => {
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
const testMonitor1 = 'Elastic journey';
const testMonitor2 = 'CNN journey';
const testMonitor3 = 'Google journey';
const testMonitor4 = 'Gmail journey';
const elastic = page.locator(`text=${testMonitor1}`);
const cnn = page.locator(`text=${testMonitor2}`);
const google = page.locator(`text=${testMonitor3}`);
const gmail = page.locator(`text=${testMonitor4}`);
before(async () => {
await enableMonitorManagedViaApi(params.kibanaUrl);
await cleanTestMonitors(params);
await addTestMonitor(params.kibanaUrl, testMonitor1, {
type: 'browser',
tags: ['tag', 'dev'],
project_id: 'test-project',
});
await addTestMonitor(params.kibanaUrl, testMonitor2, {
type: 'http',
tags: ['tag', 'qa'],
urls: 'https://github.com',
});
await addTestMonitor(params.kibanaUrl, testMonitor3, {
type: 'tcp',
tags: ['tag', 'staging'],
hosts: 'smtp',
});
await addTestMonitor(params.kibanaUrl, testMonitor4, {
type: 'icmp',
tags: ['tag', 'prod'],
hosts: '1.1.1.1',
});
await syntheticsApp.waitForLoadingToFinish();
});
step('Go to monitor-management', async () => {
await syntheticsApp.navigateToOverview();
});
step('login to Kibana', async () => {
await syntheticsApp.loginToKibana();
const invalid = await page.locator(`text=Username or password is incorrect. Please try again.`);
expect(await invalid.isVisible()).toBeFalsy();
});
step('searches by name', async () => {
await page.waitForSelector(`[data-test-subj="syntheticsOverviewGridItem"]`);
await page.waitForSelector(`text=${testMonitor1}`);
await page.waitForSelector(`text=${testMonitor2}`);
await page.waitForSelector(`text=${testMonitor3}`);
await page.focus('[data-test-subj="syntheticsOverviewSearchInput"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'Elastic', { delay: 300 });
await page.waitForSelector(`text=${testMonitor1}`);
expect(await elastic.count()).toBe(1);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(0);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'cnn', { delay: 300 });
await page.waitForSelector(`text=${testMonitor2}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(1);
expect(await google.count()).toBe(0);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'GOOGLE', { delay: 300 });
await page.waitForSelector(`text=${testMonitor3}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(1);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'Journey', { delay: 300 });
await page.waitForSelector(`text=${testMonitor1}`);
expect(await elastic.count()).toBe(1);
expect(await cnn.count()).toBe(1);
expect(await google.count()).toBe(1);
});
step('searches by tags', async () => {
await page.waitForSelector(`[data-test-subj="syntheticsOverviewGridItem"]`);
await page.waitForSelector(`text=${testMonitor1}`);
await page.waitForSelector(`text=${testMonitor2}`);
await page.waitForSelector(`text=${testMonitor3}`);
await page.click('[aria-label="Clear input"]');
await page.focus('[data-test-subj="syntheticsOverviewSearchInput"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'dev', { delay: 300 });
await page.waitForSelector(`text=${testMonitor1}`);
expect(await elastic.count()).toBe(1);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(0);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'qa', { delay: 300 });
await page.waitForSelector(`text=${testMonitor2}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(1);
expect(await google.count()).toBe(0);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'staging', { delay: 300 });
await page.waitForSelector(`text=${testMonitor3}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(1);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'prod', {
delay: 300,
});
await page.waitForSelector(`text=${testMonitor4}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(0);
expect(await gmail.count()).toBe(1);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'tag', {
delay: 300,
});
await page.waitForSelector(`text=${testMonitor1}`);
expect(await elastic.count()).toBe(1);
expect(await cnn.count()).toBe(1);
expect(await google.count()).toBe(1);
expect(await gmail.count()).toBe(1);
});
step('searches by url and host', async () => {
await page.waitForSelector(`[data-test-subj="syntheticsOverviewGridItem"]`);
await page.waitForSelector(`text=${testMonitor1}`);
await page.waitForSelector(`text=${testMonitor2}`);
await page.waitForSelector(`text=${testMonitor3}`);
await page.click('[aria-label="Clear input"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'github', { delay: 300 });
await page.waitForSelector(`text=${testMonitor2}`);
expect(await elastic.count()).toBe(0);
expect(await cnn.count()).toBe(1);
expect(await google.count()).toBe(0);
// await page.click('[aria-label="Clear input"]');
// await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'smtp', { delay: 300 });
// await page.waitForSelector(`text=${testMonitor3}`);
// expect(await elastic.count()).toBe(0);
// expect(await cnn.count()).toBe(0);
// expect(await google.count()).toBe(1);
// await page.click('[aria-label="Clear input"]');
// await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', '1.1.1.1', {
// delay: 300,
// });
// await page.waitForSelector(`text=${testMonitor4}`);
// expect(await elastic.count()).toBe(0);
// expect(await cnn.count()).toBe(0);
// expect(await google.count()).toBe(0);
// expect(await gmail.count()).toBe(1);
});
step('searches by project', async () => {
await page.click('[aria-label="Clear input"]');
await page.waitForSelector(`[data-test-subj="syntheticsOverviewGridItem"]`);
await page.waitForSelector(`text=${testMonitor1}`);
await page.waitForSelector(`text=${testMonitor2}`);
await page.waitForSelector(`text=${testMonitor3}`);
await page.focus('[data-test-subj="syntheticsOverviewSearchInput"]');
await page.type('[data-test-subj="syntheticsOverviewSearchInput"]', 'project', { delay: 300 });
await page.waitForSelector(`text=${testMonitor1}`);
expect(await elastic.count()).toBe(1);
expect(await cnn.count()).toBe(0);
expect(await google.count()).toBe(0);
});
});

View file

@ -19,11 +19,19 @@ export const enableMonitorManagedViaApi = async (kibanaUrl: string) => {
}
};
export const addTestMonitor = async (kibanaUrl: string, name: string) => {
data.name = name;
export const addTestMonitor = async (
kibanaUrl: string,
name: string,
params: Record<string, any> = { type: 'browser' }
) => {
const testData = {
...(params?.type !== 'browser' ? {} : data),
...(params || {}),
name,
locations: [{ id: 'us_central', isServiceManaged: true }],
};
try {
await axios.post(kibanaUrl + '/internal/uptime/service/monitors', data, {
await axios.post(kibanaUrl + '/internal/uptime/service/monitors', testData, {
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true' },
});

View file

@ -0,0 +1,37 @@
/*
* 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 from 'react';
import * as URL from '../../../hooks/use_url_params';
import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { NoMonitorsFound } from './no_monitors_found';
describe('NoMonitorsFound', () => {
let useUrlParamsSpy: jest.SpyInstance<[URL.GetUrlParams, URL.UpdateUrlParams]>;
let updateUrlParamsMock: jest.Mock;
beforeEach(() => {
useUrlParamsSpy = jest.spyOn(URL, 'useUrlParams');
updateUrlParamsMock = jest.fn();
useUrlParamsSpy.mockImplementation(() => [jest.fn(), updateUrlParamsMock]);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('clears url params', async () => {
const { getByText } = render(<NoMonitorsFound />);
fireEvent.click(getByText('Clear filters'));
await waitFor(() => {
expect(updateUrlParamsMock).toBeCalledWith(null);
});
});
});

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiEmptyPrompt, EuiLink, EuiText } from '@elastic/eui';
import React from 'react';
import { useUrlParams } from '../../../hooks';
export function NoMonitorsFound() {
return (
<EuiEmptyPrompt
iconType="search"
iconColor="subdued"
title={<span>{NO_MONITORS_FOUND_HEADING}</span>}
titleSize="s"
body={
<EuiText size="s">
{NO_MONITORS_FOUND_CONTENT} <ClearFilters />
</EuiText>
}
/>
);
}
export function ClearFilters() {
const [_, updateUrlParams] = useUrlParams();
return <EuiLink onClick={() => updateUrlParams(null)}>{CLEAR_FILTERS_LABEL}</EuiLink>;
}
const NO_MONITORS_FOUND_HEADING = i18n.translate(
'xpack.synthetics.overview.noMonitorsFoundHeading',
{
defaultMessage: 'No monitors found',
}
);
const NO_MONITORS_FOUND_CONTENT = i18n.translate(
'xpack.synthetics.overview.noMonitorsFoundContent',
{
defaultMessage: 'Try refining your search.',
}
);
const CLEAR_FILTERS_LABEL = i18n.translate('xpack.synthetics.overview.overview.clearFilters', {
defaultMessage: 'Clear filters',
});

View file

@ -0,0 +1,57 @@
/*
* 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 from 'react';
import * as URL from '../../../hooks/use_url_params';
import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../../utils/testing/rtl_helpers';
import { SyntheticsUrlParams } from '../../../utils/url_params/get_supported_url_params';
import { SearchField } from './search_field';
describe('SearchField', () => {
let useUrlParamsSpy: jest.SpyInstance<[URL.GetUrlParams, URL.UpdateUrlParams]>;
let useGetUrlParamsSpy: jest.SpyInstance<SyntheticsUrlParams>;
let updateUrlParamsMock: jest.Mock;
beforeEach(() => {
useUrlParamsSpy = jest.spyOn(URL, 'useUrlParams');
useGetUrlParamsSpy = jest.spyOn(URL, 'useGetUrlParams');
updateUrlParamsMock = jest.fn();
useUrlParamsSpy.mockImplementation(() => [jest.fn(), updateUrlParamsMock]);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('updates url params when searching', async () => {
const searchInput = 'test input';
const { getByTestId } = render(<SearchField />);
fireEvent.change(getByTestId('syntheticsOverviewSearchInput'), {
target: { value: searchInput },
});
await waitFor(() => {
expect(updateUrlParamsMock).toBeCalledWith({
query: searchInput,
});
});
});
it('fills search bar with query', () => {
const searchInput = 'test input';
useGetUrlParamsSpy.mockReturnValue({
query: searchInput,
} as SyntheticsUrlParams);
const { getByTestId } = render(<SearchField />);
const input = getByTestId('syntheticsOverviewSearchInput') as HTMLInputElement;
expect(input.value).toEqual(searchInput);
});
});

View file

@ -0,0 +1,47 @@
/*
* 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 { EuiFieldSearch } from '@elastic/eui';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { useGetUrlParams, useUrlParams } from '../../../hooks';
export function SearchField() {
const { query } = useGetUrlParams();
const [_, updateUrlParams] = useUrlParams();
const [search, setSearch] = useState(query || '');
useDebounce(
() => {
if (search !== query) {
updateUrlParams({ query: search });
}
},
300,
[search]
);
return (
<EuiFieldSearch
fullWidth
placeholder={PLACEHOLDER_TEXT}
value={search}
onChange={(e) => {
setSearch(e.target.value);
}}
isClearable={true}
aria-label={PLACEHOLDER_TEXT}
data-test-subj="syntheticsOverviewSearchInput"
/>
);
}
const PLACEHOLDER_TEXT = i18n.translate('xpack.synthetics.monitorManagement.filter.placeholder', {
defaultMessage: `Search by name, url, host, tag, project or location`,
});

View file

@ -5,40 +5,16 @@
* 2.0.
*/
import React, { useState } from 'react';
import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { useGetUrlParams, useUrlParams } from '../../../../hooks';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FilterGroup } from './filter_group';
import { SearchField } from '../../common/search_field';
export function ListFilters() {
const { query } = useGetUrlParams();
const updateUrlParams = useUrlParams()[1];
const [search, setSearch] = useState(query || '');
useDebounce(
() => {
updateUrlParams({ query: search });
},
300,
[search]
);
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={2}>
<EuiFieldSearch
fullWidth
placeholder={PLACEHOLDER_TEXT}
value={search}
onChange={(e) => {
setSearch(e.target.value);
}}
isClearable={true}
aria-label={PLACEHOLDER_TEXT}
/>
<SearchField />
</EuiFlexItem>
<EuiFlexItem grow={1}>
<FilterGroup />
@ -46,7 +22,3 @@ export function ListFilters() {
</EuiFlexGroup>
);
}
const PLACEHOLDER_TEXT = i18n.translate('xpack.synthetics.monitorManagement.filter.placeholder', {
defaultMessage: `Search by name, url, tag or location`,
});

View file

@ -4,11 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenu, useEuiShadow, EuiPanel } from '@elastic/eui';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render } from '../../../../utils/testing/rtl_helpers';
import { waitFor } from '@testing-library/react';
import { MonitorOverviewItem } from '../types';
import { OverviewGrid } from './overview_grid';
import * as hooks from '../../../../hooks/use_last_50_duration_chart';
@ -68,6 +69,10 @@ describe('Overview Grid', () => {
},
loaded: true,
loading: false,
status: {
downConfigs: [],
upConfigs: [],
},
},
serviceLocations: {
locations: [
@ -86,11 +91,13 @@ describe('Overview Grid', () => {
},
});
expect(getByText('Showing')).toBeInTheDocument();
expect(getByText('40')).toBeInTheDocument();
expect(getByText('Monitors')).toBeInTheDocument();
expect(queryByText('Showing all monitors')).not.toBeInTheDocument();
expect(getAllByTestId('syntheticsOverviewGridItem').length).toEqual(perPage);
await waitFor(() => {
expect(getByText('Showing')).toBeInTheDocument();
expect(getByText('40')).toBeInTheDocument();
expect(getByText('Monitors')).toBeInTheDocument();
expect(queryByText('Showing all monitors')).not.toBeInTheDocument();
expect(getAllByTestId('syntheticsOverviewGridItem').length).toEqual(perPage);
});
});
it('displays showing all monitors label when reaching the end of the list', async () => {
@ -111,6 +118,10 @@ describe('Overview Grid', () => {
},
loaded: true,
loading: false,
status: {
downConfigs: [],
upConfigs: [],
},
},
serviceLocations: {
locations: [

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState, useRef, useCallback } from 'react';
import React, { useEffect, useState, useRef, memo, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { i18n } from '@kbn/i18n';
import useThrottle from 'react-use/lib/useThrottle';
@ -23,17 +23,20 @@ import {
selectOverviewState,
setFlyoutConfig,
} from '../../../../state/overview';
import { useMonitorsSortedByStatus } from '../../../../hooks/use_monitors_sorted_by_status';
import { useGetUrlParams } from '../../../../hooks/use_url_params';
import { OverviewLoader } from './overview_loader';
import { OverviewPaginationInfo } from './overview_pagination_info';
import { OverviewGridItem } from './overview_grid_item';
import { SortFields } from './sort_fields';
import { useMonitorsSortedByStatus } from '../../../../hooks/use_monitors_sorted_by_status';
import { OverviewLoader } from './overview_loader';
import { NoMonitorsFound } from '../../common/no_monitors_found';
import { MonitorDetailFlyout } from './monitor_detail_flyout';
import { OverviewStatus } from './overview_status';
export const OverviewGrid = () => {
export const OverviewGrid = memo(() => {
const { statusFilter } = useGetUrlParams();
const {
data: { monitors },
status,
flyoutConfig,
loaded,
pageState,
@ -42,15 +45,14 @@ export const OverviewGrid = () => {
const [loadNextPage, setLoadNextPage] = useState(false);
const [page, setPage] = useState(1);
const { monitorsSortedByStatus } = useMonitorsSortedByStatus(
sortField === 'status' && monitors.length !== 0
);
const { monitorsSortedByStatus } = useMonitorsSortedByStatus();
const currentMonitors = getCurrentMonitors({
monitors,
monitorsSortedByStatus,
perPage,
page,
sortField,
statusFilter,
});
const dispatch = useDispatch();
@ -91,17 +93,20 @@ export const OverviewGrid = () => {
}
}, [loadNextPage]);
// Display no monitors found when down, up, or disabled filter produces no results
if (status && !monitorsSortedByStatus.length) {
return <NoMonitorsFound />;
}
return (
<>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<OverviewStatus />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<OverviewPaginationInfo page={page} loading={!loaded} />
<OverviewPaginationInfo
page={page}
loading={!loaded}
total={status ? monitorsSortedByStatus.length : undefined}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SortFields onSortChange={() => setPage(1)} />
@ -155,7 +160,7 @@ export const OverviewGrid = () => {
)}
</>
);
};
});
const getCurrentMonitors = ({
sortField,
@ -163,14 +168,16 @@ const getCurrentMonitors = ({
page,
monitors,
monitorsSortedByStatus,
statusFilter,
}: {
sortField: string;
perPage: number;
page: number;
monitors: MonitorOverviewItem[];
monitorsSortedByStatus: MonitorOverviewItem[];
statusFilter?: string;
}) => {
if (sortField === 'status') {
if (sortField === 'status' || statusFilter) {
return monitorsSortedByStatus.slice(0, perPage * page);
} else {
return monitors.slice(0, perPage * page);

View file

@ -13,24 +13,19 @@ import { selectOverviewState } from '../../../../state/overview';
export const OverviewPaginationInfo = ({
page,
loading,
total,
startRange,
endRange,
}: {
page: number;
loading: boolean;
total?: number;
startRange?: number;
endRange?: number;
}) => {
const {
data: { total, monitors },
loaded,
} = useSelector(selectOverviewState);
const { loaded } = useSelector(selectOverviewState);
if (loaded && !monitors.length) {
return null;
}
return loaded ? (
return loaded && total !== undefined ? (
<EuiText size="xs">
{startRange && endRange ? (
<FormattedMessage

View file

@ -7,29 +7,45 @@
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiStat, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
clearOverviewStatusErrorAction,
fetchOverviewStatusAction,
quietFetchOverviewStatusAction,
selectOverviewPageState,
selectOverviewStatus,
} from '../../../../state';
import { kibanaService } from '../../../../../../utils/kibana_service';
import { useSyntheticsRefreshContext } from '../../../../contexts';
import { useGetUrlParams } from '../../../../hooks/use_url_params';
function title(t?: number) {
return t ?? '-';
}
export function OverviewStatus() {
const { statusFilter } = useGetUrlParams();
const { status, statusError } = useSelector(selectOverviewStatus);
const pageState = useSelector(selectOverviewPageState);
const dispatch = useDispatch();
const [statusConfig, setStatusConfig] = useState({
up: status?.up,
down: status?.down,
disabledCount: status?.disabledCount,
});
const { lastRefresh } = useSyntheticsRefreshContext();
const lastRefreshRef = useRef(lastRefresh);
useEffect(() => {
dispatch(fetchOverviewStatusAction.get());
}, [dispatch, lastRefresh]);
if (lastRefresh !== lastRefreshRef.current) {
dispatch(quietFetchOverviewStatusAction.get(pageState));
lastRefreshRef.current = lastRefresh;
} else {
dispatch(fetchOverviewStatusAction.get(pageState));
}
}, [dispatch, lastRefresh, pageState]);
useEffect(() => {
if (statusError) {
@ -41,6 +57,42 @@ export function OverviewStatus() {
}
}, [dispatch, statusError]);
useEffect(() => {
if (statusFilter) {
switch (statusFilter) {
case 'up':
setStatusConfig({
up: status?.up || 0,
down: 0,
disabledCount: 0,
});
break;
case 'down': {
setStatusConfig({
up: 0,
down: status?.down || 0,
disabledCount: 0,
});
break;
}
case 'disabled': {
setStatusConfig({
up: 0,
down: 0,
disabledCount: status?.disabledCount || 0,
});
break;
}
}
} else if (status) {
setStatusConfig({
up: status?.up,
down: status?.down || 0,
disabledCount: 0,
});
}
}, [status, statusFilter]);
return (
<EuiPanel>
<EuiTitle size="xs">
@ -53,7 +105,7 @@ export function OverviewStatus() {
data-test-subj="xpack.uptime.synthetics.overview.status.up"
description={upDescription}
reverse
title={title(status?.up)}
title={title(statusConfig?.up)}
titleColor="success"
titleSize="m"
/>
@ -63,7 +115,7 @@ export function OverviewStatus() {
data-test-subj="xpack.uptime.synthetics.overview.status.down"
description={downDescription}
reverse
title={title(status?.down)}
title={title(statusConfig?.down)}
titleColor="danger"
titleSize="m"
/>
@ -73,7 +125,7 @@ export function OverviewStatus() {
data-test-subj="xpack.uptime.synthetics.overview.status.disabled"
description={disabledDescription}
reverse
title={title(status?.disabledCount)}
title={title(statusConfig?.disabledCount)}
titleColor="subdued"
titleSize="m"
/>

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 from 'react';
import * as URL from '../../../../hooks/use_url_params';
import { fireEvent } from '@testing-library/react';
import { render } from '../../../../utils/testing/rtl_helpers';
import { SyntheticsUrlParams } from '../../../../utils/url_params/get_supported_url_params';
import { QuickFilters } from './quick_filters';
describe('QuickFilters', () => {
let useUrlParamsSpy: jest.SpyInstance<[URL.GetUrlParams, URL.UpdateUrlParams]>;
let useGetUrlParamsSpy: jest.SpyInstance<SyntheticsUrlParams>;
let updateUrlParamsMock: jest.Mock;
beforeEach(() => {
useUrlParamsSpy = jest.spyOn(URL, 'useUrlParams');
useGetUrlParamsSpy = jest.spyOn(URL, 'useGetUrlParams');
updateUrlParamsMock = jest.fn();
useUrlParamsSpy.mockImplementation(() => [jest.fn(), updateUrlParamsMock]);
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each(['Up', 'Down', 'Disabled'])('updates url params when filter is clicked', (status) => {
const { getByText } = render(<QuickFilters />);
fireEvent.click(getByText(status));
expect(updateUrlParamsMock).toBeCalledWith({
statusFilter: status.toLowerCase(),
});
});
it.each(['Up', 'Down', 'Disabled'])('deselects filer', (status) => {
useGetUrlParamsSpy.mockReturnValue({
statusFilter: status.toLowerCase(),
} as SyntheticsUrlParams);
const { getByText } = render(<QuickFilters />);
fireEvent.click(getByText(status));
expect(updateUrlParamsMock).toBeCalledWith({
statusFilter: undefined,
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFilterGroup, EuiFilterButton } from '@elastic/eui';
import { useGetUrlParams, useUrlParams } from '../../../../hooks';
export const QuickFilters = () => {
const { statusFilter } = useGetUrlParams();
const [_, updateUrlParams] = useUrlParams();
const handleFilterUpdate = (monitorStatus: string) => {
return () => {
updateUrlParams({ statusFilter: statusFilter !== monitorStatus ? monitorStatus : undefined });
};
};
return (
<EuiFilterGroup>
<EuiFilterButton hasActiveFilters={statusFilter === 'up'} onClick={handleFilterUpdate('up')}>
{UP_LABEL}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={statusFilter === 'down'}
onClick={handleFilterUpdate('down')}
>
{DOWN_LABEL}
</EuiFilterButton>
<EuiFilterButton
hasActiveFilters={statusFilter === 'disabled'}
onClick={handleFilterUpdate('disabled')}
>
{DISABLED_LABEL}
</EuiFilterButton>
</EuiFilterGroup>
);
};
const DOWN_LABEL = i18n.translate('xpack.synthetics.overview.status.filters.down', {
defaultMessage: 'Down',
});
const UP_LABEL = i18n.translate('xpack.synthetics.overview.status.filters.up', {
defaultMessage: 'Up',
});
const DISABLED_LABEL = i18n.translate('xpack.synthetics.overview.status.filters.disabled', {
defaultMessage: 'Disabled',
});

View file

@ -4,17 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { EuiFlexGroup, EuiSpacer, EuiFlexItem } from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { Redirect } from 'react-router-dom';
import { useEnablement } from '../../../hooks';
import { Redirect, useLocation } from 'react-router-dom';
import { useEnablement, useGetUrlParams } from '../../../hooks';
import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context';
import {
fetchMonitorOverviewAction,
quietFetchOverviewAction,
selectOverviewState,
setOverviewPageStateAction,
selectOverviewPageState,
selectServiceLocationsState,
} from '../../../state';
import { getServiceLocations } from '../../../state/service_locations';
@ -24,6 +25,10 @@ import { GETTING_STARTED_ROUTE, MONITORS_ROUTE } from '../../../../../../common/
import { useMonitorList } from '../hooks/use_monitor_list';
import { useOverviewBreadcrumbs } from './use_breadcrumbs';
import { OverviewGrid } from './overview/overview_grid';
import { OverviewStatus } from './overview/overview_status';
import { QuickFilters } from './overview/quick_filters';
import { SearchField } from '../common/search_field';
import { NoMonitorsFound } from '../common/no_monitors_found';
export const OverviewPage: React.FC = () => {
useTrackPageview({ app: 'synthetics', path: 'overview' });
@ -33,8 +38,10 @@ export const OverviewPage: React.FC = () => {
const dispatch = useDispatch();
const { refreshApp, lastRefresh } = useSyntheticsRefreshContext();
const { query } = useGetUrlParams();
const { search } = useLocation();
const { pageState } = useSelector(selectOverviewState);
const pageState = useSelector(selectOverviewPageState);
const { loading: locationsLoading, locationsLoaded } = useSelector(selectServiceLocationsState);
useEffect(() => {
@ -50,10 +57,20 @@ 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));
}, [dispatch, pageState]);
// fetch overview for refresh
useEffect(() => {
dispatch(quietFetchOverviewAction.get(pageState));
}, [dispatch, pageState, lastRefresh]);
@ -65,13 +82,49 @@ export const OverviewPage: React.FC = () => {
const { syntheticsMonitors, loading: monitorsLoading, loaded: monitorsLoaded } = useMonitorList();
if (!enablementLoading && isEnabled && !monitorsLoading && syntheticsMonitors.length === 0) {
if (
!search &&
enablementLoading &&
isEnabled &&
!monitorsLoading &&
syntheticsMonitors.length === 0
) {
return <Redirect to={GETTING_STARTED_ROUTE} />;
}
if (!enablementLoading && !isEnabled && monitorsLoaded && syntheticsMonitors.length === 0) {
if (
!search &&
!enablementLoading &&
!isEnabled &&
monitorsLoaded &&
syntheticsMonitors.length === 0
) {
return <Redirect to={MONITORS_ROUTE} />;
}
return <OverviewGrid />;
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<SearchField />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<QuickFilters />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{Boolean(!monitorsLoaded || syntheticsMonitors?.length > 0) && (
<>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<OverviewStatus />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<OverviewGrid />
</>
)}
{monitorsLoaded && syntheticsMonitors?.length === 0 && <NoMonitorsFound />}
</>
);
};

View file

@ -6,8 +6,10 @@
*/
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { SyntheticsUrlParams } from '../utils/url_params/get_supported_url_params';
import { useMonitorsSortedByStatus } from './use_monitors_sorted_by_status';
import { WrappedHelper } from '../utils/testing';
import * as URL from './use_url_params';
describe('useMonitorsSortedByStatus', () => {
const location1 = {
@ -24,7 +26,11 @@ describe('useMonitorsSortedByStatus', () => {
isServiceManaged: true,
};
let useGetUrlParamsSpy: jest.SpyInstance<SyntheticsUrlParams>;
beforeEach(() => {
useGetUrlParamsSpy = jest.spyOn(URL, 'useGetUrlParams');
jest.clearAllMocks();
});
@ -139,7 +145,7 @@ describe('useMonitorsSortedByStatus', () => {
};
it('returns monitors down first when sort order is asc', () => {
const { result } = renderHook(() => useMonitorsSortedByStatus(true), {
const { result } = renderHook(() => useMonitorsSortedByStatus(), {
wrapper: WrapperWithState,
});
expect(result.current).toEqual({
@ -190,7 +196,7 @@ describe('useMonitorsSortedByStatus', () => {
});
it('returns monitors up first when sort order is desc', () => {
const { result } = renderHook(() => useMonitorsSortedByStatus(true), {
const { result } = renderHook(() => useMonitorsSortedByStatus(), {
wrapper: ({ children }: { children: React.ReactElement }) => (
<WrapperWithState sortOrder="desc">{children}</WrapperWithState>
),
@ -241,4 +247,103 @@ describe('useMonitorsSortedByStatus', () => {
},
});
});
it('returns only up monitors when statusFilter is down', () => {
useGetUrlParamsSpy.mockReturnValue({
statusFilter: 'up',
} as SyntheticsUrlParams);
const { result } = renderHook(() => useMonitorsSortedByStatus(), {
wrapper: ({ children }: { children: React.ReactElement }) => (
<WrapperWithState sortOrder="desc">{children}</WrapperWithState>
),
});
expect(result.current).toEqual({
monitorsSortedByStatus: [
{
id: 'test-monitor-1',
name: 'Test monitor 1',
location: location2,
isEnabled: true,
},
{
id: 'test-monitor-2',
name: 'Test monitor 2',
location: location2,
isEnabled: true,
},
{
id: 'test-monitor-3',
name: 'Test monitor 3',
location: location2,
isEnabled: true,
},
],
downMonitors: {
'test-monitor-1': ['US Central'],
'test-monitor-2': ['US Central'],
'test-monitor-3': ['US Central'],
},
});
});
it('returns only down monitors when statusFilter is down', () => {
useGetUrlParamsSpy.mockReturnValue({
statusFilter: 'down',
} as SyntheticsUrlParams);
const { result } = renderHook(() => useMonitorsSortedByStatus(), {
wrapper: ({ children }: { children: React.ReactElement }) => (
<WrapperWithState sortOrder="desc">{children}</WrapperWithState>
),
});
expect(result.current).toEqual({
monitorsSortedByStatus: [
{
id: 'test-monitor-2',
name: 'Test monitor 2',
location: location1,
isEnabled: true,
},
{
id: 'test-monitor-3',
name: 'Test monitor 3',
location: location1,
isEnabled: true,
},
],
downMonitors: {
'test-monitor-1': ['US Central'],
'test-monitor-2': ['US Central'],
'test-monitor-3': ['US Central'],
},
});
});
it('returns only disabled monitors when statusFilter is down', () => {
useGetUrlParamsSpy.mockReturnValue({
statusFilter: 'disabled',
} as SyntheticsUrlParams);
const { result } = renderHook(() => useMonitorsSortedByStatus(), {
wrapper: ({ children }: { children: React.ReactElement }) => (
<WrapperWithState sortOrder="desc">{children}</WrapperWithState>
),
});
expect(result.current).toEqual({
monitorsSortedByStatus: [
{
id: 'test-monitor-1',
name: 'Test monitor 1',
location: location1,
isEnabled: false,
},
],
downMonitors: {
'test-monitor-1': ['US Central'],
'test-monitor-2': ['US Central'],
'test-monitor-3': ['US Central'],
},
});
});
});

View file

@ -11,8 +11,10 @@ import { useSelector } from 'react-redux';
import { MonitorOverviewItem } from '../../../../common/runtime_types';
import { selectOverviewState } from '../state/overview';
import { useLocationNames } from './use_location_names';
import { useGetUrlParams } from './use_url_params';
export function useMonitorsSortedByStatus(shouldUpdate: boolean) {
export function useMonitorsSortedByStatus() {
const { statusFilter } = useGetUrlParams();
const {
pageState: { sortOrder },
data: { monitors },
@ -70,6 +72,25 @@ export function useMonitorsSortedByStatus(shouldUpdate: boolean) {
}, [monitors, locationNames, downMonitors, status]);
return useMemo(() => {
switch (statusFilter) {
case 'down':
return {
monitorsSortedByStatus: monitorsSortedByStatus.down,
downMonitors: downMonitors.current,
};
case 'up':
return {
monitorsSortedByStatus: monitorsSortedByStatus.up,
downMonitors: downMonitors.current,
};
case 'disabled':
return {
monitorsSortedByStatus: monitorsSortedByStatus.disabled,
downMonitors: downMonitors.current,
};
default:
break;
}
const upAndDownMonitors =
sortOrder === 'asc'
? [...monitorsSortedByStatus.down, ...monitorsSortedByStatus.up]
@ -79,5 +100,5 @@ export function useMonitorsSortedByStatus(shouldUpdate: boolean) {
monitorsSortedByStatus: [...upAndDownMonitors, ...monitorsSortedByStatus.disabled],
downMonitors: downMonitors.current,
};
}, [downMonitors, monitorsSortedByStatus, sortOrder]);
}, [downMonitors, monitorsSortedByStatus, sortOrder, statusFilter]);
}

View file

@ -14,10 +14,13 @@ import { SyntheticsRefreshContext } from '../contexts';
interface MockUrlParamsComponentProps {
hook: SyntheticsUrlParamsHook;
updateParams?: { [key: string]: any };
updateParams?: { [key: string]: any } | null;
}
const UseUrlParamsTestComponent = ({ hook, updateParams }: MockUrlParamsComponentProps) => {
const UseUrlParamsTestComponent = ({
hook,
updateParams = { dateRangeStart: 'now-12d', dateRangeEnd: 'now' },
}: MockUrlParamsComponentProps) => {
const [params, setParams] = useState({});
const [getUrlParams, updateUrlParams] = hook();
const queryParams = getUrlParams();
@ -27,7 +30,7 @@ const UseUrlParamsTestComponent = ({ hook, updateParams }: MockUrlParamsComponen
<button
id="setUrlParams"
onClick={() => {
updateUrlParams(updateParams || { dateRangeStart: 'now-12d', dateRangeEnd: 'now' });
updateUrlParams(updateParams);
}}
>
Set url params
@ -65,4 +68,22 @@ describe('useUrlParams', () => {
});
pushSpy.mockClear();
});
it('clears search when null is passed to params', async () => {
const { findByText, history } = render(
<SyntheticsRefreshContext.Provider value={{ lastRefresh: 123, refreshApp: jest.fn() }}>
<UseUrlParamsTestComponent hook={useUrlParams} updateParams={null} />
</SyntheticsRefreshContext.Provider>
);
const pushSpy = jest.spyOn(history, 'push');
const setUrlParamsButton = await findByText('Set url params');
userEvent.click(setUrlParamsButton);
expect(pushSpy).toHaveBeenCalledWith({
pathname: '/',
search: undefined,
});
pushSpy.mockClear();
});
});

View file

@ -15,9 +15,11 @@ function getParsedParams(search: string) {
}
export type GetUrlParams = () => SyntheticsUrlParams;
export type UpdateUrlParams = (updatedParams: {
[key: string]: string | number | boolean | undefined;
}) => void;
export type UpdateUrlParams = (
updatedParams: {
[key: string]: string | number | boolean | undefined;
} | null
) => void;
export type SyntheticsUrlParamsHook = () => [GetUrlParams, UpdateUrlParams];
@ -32,31 +34,33 @@ export const useUrlParams: SyntheticsUrlParamsHook = () => {
const history = useHistory();
const updateUrlParams: UpdateUrlParams = useCallback(
(updatedParams) => {
(updatedParams, clearAllParams = false) => {
const currentParams = getParsedParams(search);
const mergedParams = {
...currentParams,
...updatedParams,
};
const updatedSearch = stringify(
// drop any parameters that have no value
Object.keys(mergedParams).reduce((params, key) => {
const value = mergedParams[key];
if (value === undefined || value === '') {
return params;
}
const updatedSearch = updatedParams
? stringify(
// drop any parameters that have no value
Object.keys(mergedParams).reduce((params, key) => {
const value = mergedParams[key];
if (value === undefined || value === '') {
return params;
}
return {
...params,
[key]: value,
};
}, {})
);
return {
...params,
[key]: value,
};
}, {})
)
: null;
// only update the URL if the search has actually changed
if (search !== updatedSearch) {
history.push({ pathname, search: updatedSearch });
history.push({ pathname, search: updatedSearch || undefined });
}
},
[history, pathname, search]

View file

@ -93,7 +93,7 @@ const getRoutes = (
values: { baseTitle },
}),
path: GETTING_STARTED_ROUTE,
component: () => <GettingStartedPage />,
component: GettingStartedPage,
dataTestSubj: 'syntheticsGettingStartedPage',
pageSectionProps: {
alignment: 'center',
@ -294,7 +294,7 @@ const getRoutes = (
values: { baseTitle },
}),
path: STEP_DETAIL_ROUTE,
component: () => <StepDetailPage />,
component: StepDetailPage,
dataTestSubj: 'syntheticsMonitorEditPage',
pageHeader: {
pageTitle: <StepTitle />,
@ -312,7 +312,7 @@ const getRoutes = (
values: { baseTitle },
}),
path: ERROR_DETAILS_ROUTE,
component: () => <ErrorDetailsPage />,
component: ErrorDetailsPage,
dataTestSubj: 'syntheticsMonitorEditPage',
pageHeader: {
pageTitle: (

View file

@ -26,8 +26,14 @@ export const quietFetchOverviewAction = createAsyncAction<
MonitorOverviewResult
>('quietFetchOverviewAction');
export const fetchOverviewStatusAction = createAsyncAction<undefined, OverviewStatus>(
'fetchOverviewStatusAction'
);
export const fetchOverviewStatusAction = createAsyncAction<
MonitorOverviewPageState,
OverviewStatus
>('fetchOverviewStatusAction');
export const quietFetchOverviewStatusAction = createAsyncAction<
MonitorOverviewPageState,
OverviewStatus
>('quietFetchOverviewStatusAction');
export const clearOverviewStatusErrorAction = createAction<void>('clearOverviewStatusErrorAction');

View file

@ -10,6 +10,7 @@ import { SYNTHETICS_API_URLS } from '../../../../../common/constants';
import {
MonitorOverviewResult,
MonitorOverviewResultCodec,
FetchMonitorOverviewQueryArgs,
OverviewStatus,
OverviewStatusType,
} from '../../../../../common/runtime_types';
@ -24,15 +25,34 @@ export const fetchSyntheticsMonitor = async (
return apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${monitorId}`);
};
function toMonitorOverviewQueryArgs(
pageState: MonitorOverviewPageState
): FetchMonitorOverviewQueryArgs {
return {
query: pageState.query,
tags: pageState.tags,
locations: pageState.locations,
monitorType: pageState.monitorType,
sortField: pageState.sortField,
sortOrder: pageState.sortOrder,
searchFields: [],
};
}
export const fetchMonitorOverview = async (
pageState: MonitorOverviewPageState
): Promise<MonitorOverviewResult> => {
const params = toMonitorOverviewQueryArgs(pageState);
return await apiService.get(
SYNTHETICS_API_URLS.SYNTHETICS_OVERVIEW,
{ perPage: pageState.perPage, sortOrder: pageState.sortOrder, sortField: pageState.sortField },
params,
MonitorOverviewResultCodec
);
};
export const fetchOverviewStatus = async (): Promise<OverviewStatus> =>
apiService.get(SYNTHETICS_API_URLS.OVERVIEW_STATUS, {}, OverviewStatusType);
export const fetchOverviewStatus = async (
pageState: MonitorOverviewPageState
): Promise<OverviewStatus> => {
const params = toMonitorOverviewQueryArgs(pageState);
return await apiService.get(SYNTHETICS_API_URLS.OVERVIEW_STATUS, params, OverviewStatusType);
};

View file

@ -6,12 +6,12 @@
*/
import { takeLatest, takeLeading } from 'redux-saga/effects';
import { fetchUpsertSuccessAction } from '../monitor_list';
import { fetchEffectFactory } from '../utils/fetch_effect';
import {
fetchMonitorOverviewAction,
fetchOverviewStatusAction,
quietFetchOverviewAction,
fetchOverviewStatusAction,
quietFetchOverviewStatusAction,
} from './actions';
import { fetchMonitorOverview, fetchOverviewStatus } from './api';
@ -28,7 +28,7 @@ export function* fetchMonitorOverviewEffect() {
export function* fetchOverviewStatusEffect() {
yield takeLatest(
[fetchOverviewStatusAction.get, fetchUpsertSuccessAction],
[fetchOverviewStatusAction.get, quietFetchOverviewStatusAction.get],
fetchEffectFactory(
fetchOverviewStatus,
fetchOverviewStatusAction.success,

View file

@ -7,11 +7,8 @@
import { createReducer } from '@reduxjs/toolkit';
import { MonitorOverviewResult, OverviewStatus } from '../../../../../common/runtime_types';
import { MonitorOverviewState } from './models';
import { IHttpSerializedFetchError } from '../utils/http_error';
import { MonitorOverviewPageState, MonitorOverviewFlyoutConfig } from './models';
import {
clearOverviewStatusErrorAction,
fetchMonitorOverviewAction,
@ -21,17 +18,6 @@ import {
setOverviewPageStateAction,
} from './actions';
export interface MonitorOverviewState {
data: MonitorOverviewResult;
pageState: MonitorOverviewPageState;
flyoutConfig: MonitorOverviewFlyoutConfig;
loading: boolean;
loaded: boolean;
error: IHttpSerializedFetchError | null;
status: OverviewStatus | null;
statusError: IHttpSerializedFetchError | null;
}
const initialState: MonitorOverviewState = {
data: {
total: 0,
@ -54,7 +40,6 @@ const initialState: MonitorOverviewState = {
export const monitorOverviewReducer = createReducer(initialState, (builder) => {
builder
.addCase(fetchMonitorOverviewAction.get, (state, action) => {
state.pageState = action.payload;
state.loading = true;
state.loaded = false;
})
@ -80,6 +65,9 @@ export const monitorOverviewReducer = createReducer(initialState, (builder) => {
};
state.loaded = false;
})
.addCase(fetchOverviewStatusAction.get, (state) => {
state.status = null;
})
.addCase(setFlyoutConfig, (state, action) => {
state.flyoutConfig = action.payload;
})

View file

@ -4,9 +4,16 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MonitorOverviewResult, OverviewStatus } from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '../utils/http_error';
export interface MonitorOverviewPageState {
perPage: number;
query?: string;
tags?: string[];
monitorType?: string[];
locations?: string[];
sortOrder: 'asc' | 'desc';
sortField: string;
}
@ -15,3 +22,14 @@ export type MonitorOverviewFlyoutConfig = {
monitorId: string;
location: string;
} | null;
export interface MonitorOverviewState {
flyoutConfig: MonitorOverviewFlyoutConfig;
data: MonitorOverviewResult;
pageState: MonitorOverviewPageState;
loading: boolean;
loaded: boolean;
error: IHttpSerializedFetchError | null;
status: OverviewStatus | null;
statusError: IHttpSerializedFetchError | null;
}

View file

@ -10,6 +10,7 @@ import { createSelector } from 'reselect';
import { SyntheticsAppState } from '../root_reducer';
export const selectOverviewState = (state: SyntheticsAppState) => state.overview;
export const selectOverviewPageState = (state: SyntheticsAppState) => state.overview.pageState;
export const selectOverviewDataState = createSelector(selectOverviewState, (state) => state.data);
export const selectOverviewStatus = ({
overview: { status, statusError },

View file

@ -52,11 +52,25 @@ export const getSyntheticsMonitorSavedObjectType = (
},
},
},
hosts: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
journey_id: {
type: 'keyword',
},
project_id: {
type: 'keyword',
fields: {
text: {
type: 'text',
},
},
},
origin: {
type: 'keyword',
@ -82,6 +96,11 @@ export const getSyntheticsMonitorSavedObjectType = (
},
tags: {
type: 'keyword',
fields: {
text: {
type: 'text',
},
},
},
schedule: {
properties: {

View file

@ -12,7 +12,7 @@ import { EncryptedSyntheticsMonitor, ServiceLocations } from '../../common/runti
import { monitorAttributes } from '../../common/types/saved_objects';
import { syntheticsMonitorType } from '../legacy_uptime/lib/saved_objects/synthetics_monitor';
const querySchema = schema.object({
export const QuerySchema = schema.object({
page: schema.maybe(schema.number()),
perPage: schema.maybe(schema.number()),
sortField: schema.maybe(schema.string()),
@ -27,7 +27,16 @@ const querySchema = schema.object({
searchAfter: schema.maybe(schema.arrayOf(schema.string())),
});
type MonitorsQuery = TypeOf<typeof querySchema>;
export type MonitorsQuery = TypeOf<typeof QuerySchema>;
export const SEARCH_FIELDS = [
'name',
'tags.text',
'locations.id.text',
'urls',
'hosts',
'project_id.text',
];
export const getMonitors = (
request: MonitorsQuery,
@ -51,9 +60,9 @@ export const getMonitors = (
const locationFilter = parseLocationFilter(syntheticsService.locations, locations);
const filters =
getKqlFilter('tags', tags) +
getKqlFilter('type', monitorType) +
getKqlFilter('locations.id', locationFilter);
getKqlFilter({ field: 'tags', values: tags }) +
getKqlFilter({ field: 'type', values: monitorType }) +
getKqlFilter({ field: 'locations.id', values: locationFilter });
return savedObjectsClient.find({
type: syntheticsMonitorType,
@ -61,7 +70,7 @@ export const getMonitors = (
page,
sortField: sortField === 'schedule.keyword' ? 'schedule.number' : sortField,
sortOrder,
searchFields: ['name', 'tags.text', 'locations.id.text', 'urls'],
searchFields: ['name', 'tags.text', 'locations.id.text', 'urls', 'project_id.text'],
search: query ? `${query}*` : undefined,
filter: filters + filter,
fields,
@ -69,18 +78,32 @@ export const getMonitors = (
});
};
export const getKqlFilter = (field: string, values?: string | string[], operator = 'OR') => {
export const getKqlFilter = ({
field,
values,
operator = 'OR',
searchAtRoot = false,
}: {
field: string;
values?: string | string[];
operator?: string;
searchAtRoot?: boolean;
}) => {
if (!values) {
return '';
}
const fieldKey = `${monitorAttributes}.${field}`;
if (Array.isArray(values)) {
return `${fieldKey}:${values.join(` ${operator} ${fieldKey}:`)}`;
let fieldKey = '';
if (searchAtRoot) {
fieldKey = `${field}`;
} else {
fieldKey = `${monitorAttributes}.${field}`;
}
return `${fieldKey}:${values}`;
if (Array.isArray(values)) {
return `${fieldKey}:"${values.join(`" ${operator} ${fieldKey}:"`)}"`;
}
return `${fieldKey}:"${values}"`;
};
const parseLocationFilter = (serviceLocations: ServiceLocations, locations?: string | string[]) => {

View file

@ -47,10 +47,10 @@ export const deleteSyntheticsMonitorProjectRoute: SyntheticsRestApiRouteFactory
{
filter: `${syntheticsMonitorType}.attributes.${
ConfigKey.PROJECT_ID
}: "${decodedProjectName}" AND ${getKqlFilter(
'journey_id',
monitorsToDelete.map((id: string) => `"${id}"`)
)}`,
}: "${decodedProjectName}" AND ${getKqlFilter({
field: 'journey_id',
values: monitorsToDelete.map((id: string) => `${id}`),
})}`,
fields: [],
perPage: 500,
},

View file

@ -12,20 +12,7 @@ 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 } from '../common';
const querySchema = schema.object({
page: schema.maybe(schema.number()),
perPage: schema.maybe(schema.number()),
sortField: schema.maybe(schema.string()),
sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])),
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())])),
locations: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
status: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
});
import { getMonitors, QuerySchema, SEARCH_FIELDS } from '../common';
export const getSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
@ -63,7 +50,7 @@ export const getAllSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () =>
method: 'GET',
path: API_URLS.SYNTHETICS_MONITORS,
validate: {
query: querySchema,
query: QuerySchema,
},
handler: async ({ request, savedObjectsClient, syntheticsMonitorClient }): Promise<any> => {
const { filters, query } = request.query;
@ -109,15 +96,17 @@ export const getSyntheticsMonitorOverviewRoute: SyntheticsRestApiRouteFactory =
method: 'GET',
path: SYNTHETICS_API_URLS.SYNTHETICS_OVERVIEW,
validate: {
query: querySchema,
query: QuerySchema,
},
handler: async ({ request, savedObjectsClient, syntheticsMonitorClient }): Promise<any> => {
const { sortField, sortOrder } = request.query;
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,
sortOrder,
perPage: 500,
search: query ? `${query}*` : undefined,
searchFields: SEARCH_FIELDS,
});
const allMonitorIds: string[] = [];

View file

@ -6,7 +6,6 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/types';
import { schema } from '@kbn/config-schema';
import datemath, { Unit } from '@kbn/datemath';
import { IKibanaResponse, SavedObjectsClientContract } from '@kbn/core/server';
import { SYNTHETICS_API_URLS } from '../../../common/constants';
@ -16,6 +15,7 @@ import { getMonitors } from '../common';
import { UptimeEsClient } from '../../legacy_uptime/lib/lib';
import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client';
import { ConfigKey, OverviewStatus, OverviewStatusMetaData } from '../../../common/runtime_types';
import { QuerySchema, MonitorsQuery } from '../common';
/**
* Helper function that converts a monitor's schedule to a value to use to generate
@ -147,10 +147,12 @@ export async function queryMonitorStatus(
export async function getStatus(
uptimeEsClient: UptimeEsClient,
savedObjectsClient: SavedObjectsClientContract,
syntheticsMonitorClient: SyntheticsMonitorClient
syntheticsMonitorClient: SyntheticsMonitorClient,
params: MonitorsQuery
) {
let monitors;
const enabledIds: Array<string | undefined> = [];
const { query } = params;
let monitors;
let disabledCount = 0;
let page = 1;
let maxPeriod = 0;
@ -168,6 +170,7 @@ export async function getStatus(
page,
sortField: 'name.keyword',
sortOrder: 'asc',
query,
},
syntheticsMonitorClient.syntheticsService,
savedObjectsClient
@ -204,16 +207,18 @@ export const createGetCurrentStatusRoute: SyntheticsRestApiRouteFactory = (libs:
method: 'GET',
path: SYNTHETICS_API_URLS.OVERVIEW_STATUS,
validate: {
query: schema.object({}),
query: QuerySchema,
},
handler: async ({
uptimeEsClient,
savedObjectsClient,
syntheticsMonitorClient,
response,
request,
}): Promise<IKibanaResponse<OverviewStatus>> => {
const params = request.query;
return response.ok({
body: await getStatus(uptimeEsClient, savedObjectsClient, syntheticsMonitorClient),
body: await getStatus(uptimeEsClient, savedObjectsClient, syntheticsMonitorClient, params),
});
},
});

View file

@ -6,14 +6,14 @@
*/
import { SimpleSavedObject } from '@kbn/core/public';
import { MonitorFields } from '@kbn/synthetics-plugin/common/runtime_types';
import { SyntheticsMonitor, MonitorFields } from '@kbn/synthetics-plugin/common/runtime_types';
import { SYNTHETICS_API_URLS, API_URLS } from '@kbn/synthetics-plugin/common/constants';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { getFixtureJson } from '../uptime/rest/helper/get_fixture_json';
export default function ({ getService }: FtrProviderContext) {
describe('[GET] /internal/synthetics/overview', function () {
describe('GetMonitorsOverview', function () {
this.tags('skipCloud');
const supertest = getService('supertest');
@ -62,7 +62,7 @@ export default function ({ getService }: FtrProviderContext) {
for (let i = 0; i < 20; i++) {
monitors.push({
..._monitors[0],
name: `${_monitors[0].name}${i}`,
name: `${_monitors[0].name} ${i}`,
});
}
});
@ -91,6 +91,32 @@ export default function ({ getService }: FtrProviderContext) {
);
}
});
it('accepts search queries', async () => {
let savedMonitors: Array<SimpleSavedObject<SyntheticsMonitor>> = [];
try {
const savedResponse = await Promise.all(monitors.map(saveMonitor));
savedMonitors = savedResponse;
const apiResponse = await supertest.get(SYNTHETICS_API_URLS.SYNTHETICS_OVERVIEW).query({
query: '19',
});
expect(apiResponse.body.total).eql(2);
expect(apiResponse.body.allMonitorIds.sort()).eql(
savedMonitors
.filter((monitor) => monitor.attributes.name.includes('19'))
.map((monitor) => monitor.id)
);
expect(apiResponse.body.monitors.length).eql(2);
} finally {
await Promise.all(
savedMonitors.map((monitor) => {
return deleteMonitor(monitor.id);
})
);
}
});
});
});
}