diff --git a/package.json b/package.json index e9645353fbb993..552b5851673371 100644 --- a/package.json +++ b/package.json @@ -1201,11 +1201,13 @@ "react-use": "^15.3.8", "react-virtualized": "^9.22.5", "react-window": "^1.8.10", + "react-window-infinite-loader": "^1.0.9", "reduce-reducers": "^1.0.4", "redux": "^4.2.1", "redux-actions": "^2.6.5", "redux-devtools-extension": "^2.13.8", "redux-saga": "^1.1.3", + "redux-saga-testing": "^2.0.2", "redux-thunk": "^2.4.2", "redux-thunks": "^1.0.0", "reflect-metadata": "^0.2.1", @@ -1597,6 +1599,7 @@ "@types/react-test-renderer": "^17.0.2", "@types/react-virtualized": "^9.21.22", "@types/react-window": "^1.8.8", + "@types/react-window-infinite-loader": "^1.0.9", "@types/redux-actions": "^2.6.1", "@types/resolve": "^1.20.1", "@types/seedrandom": ">=2.0.0 <4.0.0", diff --git a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts index 0003360c707b63..23ac30a5ff7b1f 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/constants/synthetics/rest_api.ts @@ -26,6 +26,7 @@ export enum SYNTHETICS_API_URLS { SYNTHETICS_OVERVIEW = '/internal/synthetics/overview', PINGS = '/internal/synthetics/pings', PING_STATUSES = '/internal/synthetics/ping_statuses', + OVERVIEW_TRENDS = '/internal/synthetics/overview_trends', OVERVIEW_STATUS = `/internal/synthetics/overview_status`, INDEX_SIZE = `/internal/synthetics/index_size`, AGENT_POLICIES = `/internal/synthetics/agent_policies`, diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts index 6f0ef862137e14..c0383eaea8b393 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts @@ -395,6 +395,7 @@ export const MonitorOverviewItemCodec = t.intersection([ schedule: t.string, }), t.partial({ + status: t.string, projectId: t.string, }), ]); diff --git a/x-pack/plugins/observability_solution/synthetics/common/types/index.ts b/x-pack/plugins/observability_solution/synthetics/common/types/index.ts index be369427c47e76..6544898d54a641 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/types/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/types/index.ts @@ -8,3 +8,4 @@ export * from './synthetics_monitor'; export * from './monitor_validation'; export * from './default_alerts'; +export * from './overview'; diff --git a/x-pack/plugins/observability_solution/synthetics/common/types/overview.ts b/x-pack/plugins/observability_solution/synthetics/common/types/overview.ts new file mode 100644 index 00000000000000..4982f0f408fcfb --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/common/types/overview.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +export interface TrendKey { + configId: string; + locationId: string; +} + +export type TrendRequest = TrendKey & { schedule: string }; + +export interface TrendDatum { + x: number; + y: number; +} + +export interface OverviewTrend { + configId: string; + locationId: string; + data: TrendDatum[]; + count: number; + min: number; + max: number; + avg: number; + sum: number; + median: number; +} + +export type TrendTable = Record; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx index 7fea62b348edd2..5b024f3c703312 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx @@ -23,8 +23,9 @@ import { useKey } from 'react-use'; import { OverviewLoader } from '../overview_loader'; import { useFilteredGroupMonitors } from './use_filtered_group_monitors'; import { MonitorOverviewItem } from '../../types'; -import { FlyoutParamProps, OverviewGridItem } from '../overview_grid_item'; import { selectOverviewStatus } from '../../../../../state/overview_status'; +import { MetricItem } from '../metric_item'; +import { FlyoutParamProps } from '../types'; const PER_ROW = 4; const DEFAULT_ROW_SIZE = 2; @@ -163,7 +164,7 @@ export const GroupGridItem = ({ key={`${monitor.id}-${monitor.location?.id}`} data-test-subj="syntheticsOverviewGridItem" > - + ))} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_items_by_group.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_items_by_group.tsx index e9069d6dfdf7c5..ad7d406d769465 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_items_by_group.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_items_by_group.tsx @@ -18,8 +18,8 @@ import { import { useFilters } from '../../../common/monitor_filters/use_filters'; import { GroupGridItem } from './grid_group_item'; import { ConfigKey, MonitorOverviewItem } from '../../../../../../../../common/runtime_types'; -import { FlyoutParamProps } from '../overview_grid_item'; import { selectOverviewState, selectServiceLocationsState } from '../../../../../state'; +import { FlyoutParamProps } from '../types'; export const GridItemsByGroup = ({ loaded, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index 516c545f3e6801..57c50bc54a5bfd 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -6,16 +6,21 @@ */ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { DARK_THEME } from '@elastic/charts'; import { useTheme } from '@kbn/observability-shared-plugin/public'; -import { useDispatch, useSelector } from 'react-redux'; import moment from 'moment'; +import { useSelector, useDispatch } from 'react-redux'; + import { MetricItemBody } from './metric_item/metric_item_body'; -import { MetricItemExtra } from './metric_item/metric_item_extra'; -import { selectErrorPopoverState, toggleErrorPopoverOpen } from '../../../../state'; +import { + selectErrorPopoverState, + selectOverviewTrends, + toggleErrorPopoverOpen, +} from '../../../../state'; import { useLocationName, useStatusByLocationOverview } from '../../../../hooks'; import { formatDuration } from '../../../../utils/formatting'; import { MonitorOverviewItem } from '../../../../../../../common/runtime_types'; @@ -26,6 +31,9 @@ import { toggleTestNowFlyoutAction, } from '../../../../state/manual_test_runs'; import { MetricItemIcon } from './metric_item_icon'; +import { MetricItemExtra } from './metric_item/metric_item_extra'; + +const METRIC_ITEM_HEIGHT = 160; export const getColor = ( theme: ReturnType, @@ -49,20 +57,14 @@ export const getColor = ( export const MetricItem = ({ monitor, - stats, - data, onClick, + style, }: { monitor: MonitorOverviewItem; - data: Array<{ x: number; y: number }>; - stats: { - medianDuration: number; - avgDuration: number; - minDuration: number; - maxDuration: number; - }; + style?: React.CSSProperties; onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void; }) => { + const trendData = useSelector(selectOverviewTrends)[monitor.configId + monitor.location.id]; const [isPopoverOpen, setIsPopoverOpen] = useState(false); const isErrorPopoverOpen = useSelector(selectErrorPopoverState); const locationName = useLocationName(monitor); @@ -77,7 +79,10 @@ export const MetricItem = ({ const dispatch = useDispatch(); return ( -
+
, + trend: trendData?.data ?? [], + extra: trendData ? ( + + ) : ( +
+ +
+ ), valueFormatter: (d: number) => formatDuration(d), color: getColor(theme, monitor.isEnabled, status), body: , @@ -167,6 +188,7 @@ export const MetricItem = ({ /> )}
+
); }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx deleted file mode 100644 index 3ea7d5e0ba5b32..00000000000000 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx +++ /dev/null @@ -1,191 +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 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'; - -describe('Overview Grid', () => { - const locationIdToName: Record = { - us_central: 'Us Central', - us_east: 'US East', - }; - const getMockData = (): MonitorOverviewItem[] => { - const data: MonitorOverviewItem[] = []; - for (let i = 0; i < 20; i++) { - data.push({ - id: `${i}`, - configId: `${i}`, - location: { - id: 'us_central', - isServiceManaged: true, - }, - name: `Monitor ${i}`, - isEnabled: true, - isStatusAlertEnabled: true, - type: 'browser', - tags: [], - schedule: '60', - }); - data.push({ - id: `${i}`, - configId: `${i}`, - location: { - id: 'us_east', - isServiceManaged: true, - }, - name: `Monitor ${i}`, - isEnabled: true, - isStatusAlertEnabled: true, - type: 'browser', - tags: [], - schedule: '60', - }); - } - return data; - }; - - const getMockChart = (): Array<{ x: number; y: number }> => { - const hits = []; - for (let i = 0; i < 20; i++) { - hits.push({ - x: i, - y: i, - }); - } - return hits; - }; - - const perPage = 20; - - it('renders correctly', async () => { - jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ - data: getMockChart(), - avgDuration: 30000, - minDuration: 0, - maxDuration: 50000, - medianDuration: 15000, - loading: false, - }); - - const { getByText, getAllByTestId, queryByText } = render(, { - state: { - overview: { - pageState: { - perPage, - }, - data: { - monitors: getMockData(), - allMonitorIds: [], // not critical for this test - total: getMockData().length, - }, - loaded: true, - loading: false, - }, - overviewStatus: { - status: { - downConfigs: {}, - upConfigs: {}, - allConfigs: getMockData().reduce((acc, cur) => { - acc[`${cur.id}-${locationIdToName[cur.location.id]}`] = { - configId: cur.configId, - monitorQueryId: cur.id, - location: locationIdToName[cur.location.id], - status: 'down', - }; - return acc; - }, {} as Record), - }, - }, - serviceLocations: { - locations: [ - { - id: 'us_central', - label: 'Us Central', - }, - { - id: 'us_east', - label: 'US East', - }, - ], - locationsLoaded: true, - loading: false, - }, - }, - }); - - 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 () => { - jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ - data: getMockChart(), - avgDuration: 30000, - minDuration: 0, - maxDuration: 50000, - medianDuration: 15000, - loading: false, - }); - - const { getByText } = render(, { - state: { - overview: { - pageState: { - perPage, - }, - data: { - monitors: getMockData().slice(0, 16), - allMonitorIds: [], // not critical for this test - total: getMockData().length, - }, - loaded: true, - loading: false, - }, - overviewStatus: { - status: { - downConfigs: {}, - upConfigs: {}, - allConfigs: getMockData().reduce((acc, cur) => { - acc[`${cur.id}-${locationIdToName[cur.location.id]}`] = { - configId: cur.configId, - monitorQueryId: cur.id, - location: locationIdToName[cur.location.id], - status: 'down', - }; - return acc; - }, {} as Record), - }, - }, - serviceLocations: { - locations: [ - { - id: 'us_central', - label: 'Us Central', - }, - { - id: 'us_east', - label: 'US East', - }, - ], - locationsLoaded: true, - loading: false, - }, - }, - }); - - expect(getByText('Showing all monitors')).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx index 3da7b03f3ad9e2..76e52685736b19 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx @@ -4,55 +4,77 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useState, useRef, memo, useCallback, useEffect } from 'react'; +import React, { useState, memo, useCallback, useEffect, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; +import InfiniteLoader from 'react-window-infinite-loader'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; import { EuiFlexGroup, EuiFlexItem, - EuiFlexGrid, EuiSpacer, EuiButtonEmpty, EuiText, - EuiProgress, + EuiAutoSizer, + EuiAutoSize, } from '@elastic/eui'; +import type { TrendRequest } from '../../../../../../../common/types'; import { SYNTHETICS_MONITORS_EMBEDDABLE } from '../../../../../embeddables/constants'; import { AddToDashboard } from '../../../common/components/add_to_dashboard'; import { useOverviewStatus } from '../../hooks/use_overview_status'; -import { useInfiniteScroll } from './use_infinite_scroll'; import { GridItemsByGroup } from './grid_by_group/grid_items_by_group'; import { GroupFields } from './grid_by_group/group_fields'; import { fetchMonitorOverviewAction, quietFetchOverviewAction, + refreshOverviewTrends, selectOverviewState, + selectOverviewTrends, setFlyoutConfig, + trendStatsBatch, } from '../../../../state/overview'; -import { useMonitorsSortedByStatus } from '../../../../hooks/use_monitors_sorted_by_status'; import { OverviewLoader } from './overview_loader'; import { OverviewPaginationInfo } from './overview_pagination_info'; -import { FlyoutParamProps, OverviewGridItem } from './overview_grid_item'; import { SortFields } from './sort_fields'; import { NoMonitorsFound } from '../../common/no_monitors_found'; import { MonitorDetailFlyout } from './monitor_detail_flyout'; +import { useSyntheticsRefreshContext } from '../../../../contexts'; +import { MetricItem } from './metric_item'; +import { FlyoutParamProps } from './types'; +import { MonitorOverviewItem } from '../types'; +import { useMonitorsSortedByStatus } from '../../../../hooks/use_monitors_sorted_by_status'; + +const ITEM_HEIGHT = 172; +const ROW_COUNT = 4; +const MAX_LIST_HEIGHT = 800; +const MIN_BATCH_SIZE = 20; +const LIST_THRESHOLD = 12; + +interface ListItem { + configId: string; + location: { id: string }; +} export const OverviewGrid = memo(() => { const { status } = useOverviewStatus({ scopeStatusByLocation: true }); + const monitorsSortedByStatus: MonitorOverviewItem[] = + useMonitorsSortedByStatus().monitorsSortedByStatus; const { data: { monitors }, flyoutConfig, loaded, - loading, pageState, groupBy: { field: groupField }, } = useSelector(selectOverviewState); + const trendData = useSelector(selectOverviewTrends); const { perPage } = pageState; + const [page, setPage] = useState(1); + const [maxItem, setMaxItem] = useState(0); + const [currentIndex, setCurrentIndex] = useState(0); const dispatch = useDispatch(); - const intersectionRef = useRef(null); - const { monitorsSortedByStatus } = useMonitorsSortedByStatus(); // fetch overview for all other page state changes useEffect(() => { @@ -64,12 +86,46 @@ export const OverviewGrid = memo(() => { [dispatch] ); const hideFlyout = useCallback(() => dispatch(setFlyoutConfig(null)), [dispatch]); + const { lastRefresh } = useSyntheticsRefreshContext(); const forceRefreshCallback = useCallback( () => dispatch(quietFetchOverviewAction.get(pageState)), [dispatch, pageState] ); - const { currentMonitors } = useInfiniteScroll({ intersectionRef, monitorsSortedByStatus }); + useEffect(() => { + if (monitorsSortedByStatus.length && maxItem) { + const batch: TrendRequest[] = []; + const chunk = monitorsSortedByStatus.slice(0, (maxItem + 1) * ROW_COUNT); + for (const item of chunk) { + if (trendData[item.configId + item.location.id] === undefined) { + batch.push({ + configId: item.configId, + locationId: item.location.id, + schedule: item.schedule, + }); + } + } + if (batch.length) dispatch(trendStatsBatch.get(batch)); + } + }, [dispatch, maxItem, monitorsSortedByStatus, trendData]); + + const listHeight = Math.min( + ITEM_HEIGHT * Math.ceil(monitorsSortedByStatus.length / ROW_COUNT), + MAX_LIST_HEIGHT + ); + + const listItems: ListItem[][] = useMemo(() => { + const acc: ListItem[][] = []; + for (let i = 0; i < monitorsSortedByStatus.length; i += ROW_COUNT) { + acc.push(monitorsSortedByStatus.slice(i, i + ROW_COUNT)); + } + return acc; + }, [monitorsSortedByStatus]); + + const listRef: React.LegacyRef> | undefined = React.createRef(); + useEffect(() => { + dispatch(refreshOverviewTrends.get()); + }, [dispatch, lastRefresh]); // Display no monitors found when down, up, or disabled filter produces no results if (status && !monitorsSortedByStatus.length && loaded) { @@ -102,63 +158,111 @@ export const OverviewGrid = memo(() => { - - {loading && } - - <> + +
{groupField === 'none' ? ( - loaded && currentMonitors.length ? ( - - {currentMonitors.map((monitor) => ( - + {({ width }: EuiAutoSize) => ( + + listItems[idx].every((m) => !!trendData[m.configId + m.location.id]) + } + itemCount={listItems.length} + loadMoreItems={(_start: number, stop: number) => + setMaxItem(Math.max(maxItem, stop)) + } + minimumBatchSize={MIN_BATCH_SIZE} + threshold={LIST_THRESHOLD} > - - - ))} - + {({ onItemsRendered }) => ( + + {({ + index: listIndex, + style, + data: listData, + }: React.PropsWithChildren>) => { + setCurrentIndex(listIndex); + return ( + + {listData[listIndex].map((_, idx) => ( + + + + ))} + {listData[listIndex].length % ROW_COUNT !== 0 && + // Adds empty items to fill out row + Array.from({ + length: ROW_COUNT - listData[listIndex].length, + }).map((_, idx) => )} + + ); + }} + + )} + + )} + ) : ( ) ) : ( )} - -
-
- {groupField === 'none' && ( - - {currentMonitors.length === monitors.length && ( - - {SHOWING_ALL_MONITORS_LABEL} - - )} - {currentMonitors.length === monitors.length && currentMonitors.length > perPage && ( - - window.scrollTo(0, 0)} - iconType="sortUp" - iconSide="right" - size="xs" - > - {SCROLL_TO_TOP_LABEL} - - - )} - - )} + {groupField === 'none' && + loaded && + // display this footer when user scrolls to end of list + currentIndex * ROW_COUNT + ROW_COUNT >= monitorsSortedByStatus.length && ( + <> + + + {monitorsSortedByStatus.length === monitors.length && ( + + {SHOWING_ALL_MONITORS_LABEL} + + )} + {monitorsSortedByStatus.length === monitors.length && + monitorsSortedByStatus.length > perPage && ( + + { + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); + listRef.current?.scrollToItem(0); + }} + iconType="sortUp" + iconSide="right" + size="xs" + > + {SCROLL_TO_TOP_LABEL} + + + )} + + + )} {flyoutConfig?.configId && flyoutConfig?.location && ( void; -}) => { - const { timestamp } = useStatusByLocationOverview({ - configId: monitor.configId, - locationId: monitor.location.id, - }); - - const { data, medianDuration, maxDuration, avgDuration, minDuration } = useLast50DurationChart({ - locationId: monitor.location?.id, - monitorId: monitor.id, - timestamp, - schedule: monitor.schedule, - }); - return ( - - ); -}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/types.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/types.ts new file mode 100644 index 00000000000000..a2e7d8581e6579 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/types.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export interface FlyoutParamProps { + id: string; + configId: string; + location: string; + locationId: string; +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/index.ts index 2e5d9e2f76d665..ce0163ff74fb93 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/index.ts @@ -10,8 +10,6 @@ export * from './use_url_params'; export * from './use_breadcrumbs'; export * from './use_enablement'; export * from './use_locations'; -export * from './use_last_x_checks'; -export * from './use_last_50_duration_chart'; export * from './use_location_name'; export * from './use_status_by_location'; export * from './use_status_by_location_overview'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts deleted file mode 100644 index 4c149ce74667fd..00000000000000 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts +++ /dev/null @@ -1,186 +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 { renderHook } from '@testing-library/react-hooks'; -import * as hooks from './use_last_x_checks'; -import { useLast50DurationChart } from './use_last_50_duration_chart'; -import { WrappedHelper } from '../utils/testing'; - -describe('useLast50DurationChart', () => { - const getMockHits = (): Array<{ 'monitor.duration.us': number[] | undefined }> => { - const hits = []; - for (let i = 0; i < 10; i++) { - hits.push({ - 'monitor.duration.us': [i], - }); - } - return hits; - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns expected results', () => { - jest.spyOn(hooks, 'useLastXChecks').mockReturnValue({ hits: getMockHits(), loading: false }); - - const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '1' }), - { wrapper: WrappedHelper } - ); - expect(result.current).toEqual({ - medianDuration: 5, - maxDuration: 9, - minDuration: 0, - avgDuration: 4.5, - data: [ - { - x: 0, - y: 9, - }, - { - x: 1, - y: 8, - }, - { - x: 2, - y: 7, - }, - { - x: 3, - y: 6, - }, - { - x: 4, - y: 5, - }, - { - x: 5, - y: 4, - }, - { - x: 6, - y: 3, - }, - { - x: 7, - y: 2, - }, - { - x: 8, - y: 1, - }, - { - x: 9, - y: 0, - }, - ], - loading: false, - }); - }); - - it('handles undefined monitor duration', () => { - const hitsWithAnUndefinedDuration = [...getMockHits()]; - hitsWithAnUndefinedDuration[1] = { 'monitor.duration.us': undefined }; - - jest - .spyOn(hooks, 'useLastXChecks') - .mockReturnValue({ hits: hitsWithAnUndefinedDuration, loading: false }); - const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '10' }), - { wrapper: WrappedHelper } - ); - - const data = [ - { - x: 0, - y: 9, - }, - { - x: 1, - y: 8, - }, - { - x: 2, - y: 7, - }, - { - x: 3, - y: 6, - }, - { - x: 4, - y: 5, - }, - { - x: 5, - y: 4, - }, - { - x: 6, - y: 3, - }, - { - x: 7, - y: 2, - }, - { - x: 9, - y: 0, - }, - ]; - - expect(result.current).toEqual({ - medianDuration: [...data].sort((a, b) => a.y - b.y)[Math.floor(data.length / 2)].y, - maxDuration: 9, - minDuration: 0, - avgDuration: 4.4, - data, - loading: false, - }); - }); - - it('passes proper params to useLastXChecks', () => { - const hitsWithAnUndefinedDuration = [...getMockHits()]; - hitsWithAnUndefinedDuration[1] = { 'monitor.duration.us': undefined }; - const monitorId = 'mock-id'; - const locationId = 'loc'; - - const spy = jest - .spyOn(hooks, 'useLastXChecks') - .mockReturnValue({ hits: hitsWithAnUndefinedDuration, loading: false }); - renderHook(() => useLast50DurationChart({ monitorId, locationId, schedule: '120' }), { - wrapper: WrappedHelper, - }); - - expect(spy).toBeCalledTimes(1); - expect(spy).toBeCalledWith({ - monitorId, - locationId, - fields: ['monitor.duration.us'], - size: 50, - schedule: '120', - }); - }); - - it('returns loading properly', () => { - const loading = true; - - jest.spyOn(hooks, 'useLastXChecks').mockReturnValue({ hits: getMockHits(), loading }); - const { result } = renderHook( - () => useLast50DurationChart({ monitorId: 'mock-id', locationId: 'loc', schedule: '3' }), - { wrapper: WrappedHelper } - ); - renderHook( - () => useLast50DurationChart({ monitorId: 'test-id', locationId: 'loc', schedule: '5' }), - { - wrapper: WrappedHelper, - } - ); - expect(result.current.loading).toEqual(loading); - }); -}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts deleted file mode 100644 index f08e025a48540b..00000000000000 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts +++ /dev/null @@ -1,87 +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 { useMemo } from 'react'; -import { useLastXChecks } from './use_last_x_checks'; - -const fields = ['monitor.duration.us']; - -export function useLast50DurationChart({ - monitorId, - locationId, - timestamp, - schedule, -}: { - monitorId: string; - timestamp?: string; - locationId: string; - schedule: string; -}) { - const { hits, loading } = useLastXChecks<{ - 'monitor.duration.us': number[] | undefined; - }>({ - monitorId, - locationId, - fields, - size: 50, - timestamp, - schedule, - }); - const { data, median, min, max, avg } = useMemo(() => { - if (loading) { - return { - data: [], - median: 0, - avg: 0, - min: 0, - max: 0, - }; - } - - // calculate min, max, average duration and median - - const coords = hits - .reverse() // results are returned in desc order by timestamp. Reverse to ensure the data is in asc order by timestamp - .map((hit, index) => { - const duration = hit?.['monitor.duration.us']?.[0]; - if (duration === undefined) { - return null; - } - return { - x: index, - y: duration, - }; - }) - .filter((item) => item !== null); - - const sortedByDuration = [...hits].sort( - (a, b) => (a?.['monitor.duration.us']?.[0] || 0) - (b?.['monitor.duration.us']?.[0] || 0) - ); - - return { - data: coords as Array<{ x: number; y: number }>, - median: sortedByDuration[Math.floor(hits.length / 2)]?.['monitor.duration.us']?.[0] || 0, - avg: - sortedByDuration.reduce((acc, curr) => acc + (curr?.['monitor.duration.us']?.[0] || 0), 0) / - hits.length, - min: sortedByDuration[0]?.['monitor.duration.us']?.[0] || 0, - max: sortedByDuration[sortedByDuration.length - 1]?.['monitor.duration.us']?.[0] || 0, - }; - }, [hits, loading]); - - return useMemo( - () => ({ - data, - medianDuration: median, - avgDuration: avg, - minDuration: min, - maxDuration: max, - loading, - }), - [data, median, avg, min, max, loading] - ); -} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx deleted file mode 100644 index 8c16902357273e..00000000000000 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.test.tsx +++ /dev/null @@ -1,203 +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 from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { getTimeRangeFilter, useLastXChecks } from './use_last_x_checks'; -import { WrappedHelper } from '../utils/testing'; -import * as searchHooks from './use_redux_es_search'; -import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/constants'; - -describe('useLastXChecks', () => { - const getMockHits = (): Array<{ fields: { 'monitor.duration.us': number[] | undefined } }> => { - const hits = []; - for (let i = 0; i < 10; i++) { - hits.push({ - fields: { - 'monitor.duration.us': [i], - }, - }); - } - return hits; - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns expected results', () => { - jest.spyOn(searchHooks, 'useReduxEsSearch').mockReturnValue({ - data: { hits: { hits: getMockHits() } }, - } as any); - - const { result } = renderHook( - () => - useLastXChecks({ - monitorId: 'mock-id', - locationId: 'loc', - size: 30, - fields: ['monitor.duration.us'], - schedule: '10', - }), - { wrapper: WrappedHelper } - ); - expect(result.current).toEqual({ - hits: [ - { - 'monitor.duration.us': [0], - }, - { - 'monitor.duration.us': [1], - }, - { - 'monitor.duration.us': [2], - }, - { - 'monitor.duration.us': [3], - }, - { - 'monitor.duration.us': [4], - }, - { - 'monitor.duration.us': [5], - }, - { - 'monitor.duration.us': [6], - }, - { - 'monitor.duration.us': [7], - }, - { - 'monitor.duration.us': [8], - }, - { - 'monitor.duration.us': [9], - }, - ], - loading: false, - }); - }); - - it('passes proper params', () => { - const spy = jest.spyOn(searchHooks, 'useReduxEsSearch').mockReturnValue({ - data: { hits: { hits: getMockHits() } }, - } as any); - - const fields = ['monitor.summary']; - const size = 30; - - renderHook( - () => - useLastXChecks({ - monitorId: 'mock-id', - locationId: 'loc', - size, - fields, - schedule: '120', - }), - { wrapper: WrappedHelper } - ); - expect(spy).toBeCalledTimes(1); - expect(spy).toBeCalledWith( - expect.objectContaining({ body: expect.objectContaining({ fields, size }) }), - expect.anything(), - expect.anything() - ); - }); - - it('returns loading properly', () => { - jest.spyOn(searchHooks, 'useReduxEsSearch').mockReturnValue({ - data: { hits: { hits: getMockHits() } }, - } as any); - - const { result } = renderHook( - () => - useLastXChecks({ - monitorId: 'mock-id', - locationId: 'loc', - size: 30, - fields: ['monitor.duration.us'], - schedule: '240', - }), - { wrapper: WrappedHelper } - ); - expect(result.current.loading).toEqual(false); - }); - - it('returns loading true when there is no data', () => { - jest.spyOn(searchHooks, 'useReduxEsSearch').mockReturnValue({ - data: undefined, - } as any); - - const { result } = renderHook( - () => - useLastXChecks({ - monitorId: 'mock-id', - locationId: 'loc', - size: 30, - fields: ['monitor.duration.us'], - schedule: '1', - }), - { wrapper: WrappedHelper } - ); - expect(result.current.loading).toEqual(true); - }); - - it('calls useEsSearch with correct index', () => { - const spy = jest.spyOn(searchHooks, 'useReduxEsSearch').mockReturnValue({ - data: { hits: { hits: getMockHits() } }, - } as any); - - const WrapperWithState = ({ children }: { children: React.ReactElement }) => { - return ( - - {children} - - ); - }; - - renderHook( - () => - useLastXChecks({ - monitorId: 'mock-id', - locationId: 'loc', - size: 30, - fields: ['monitor.duration.us'], - schedule: '3', - }), - { wrapper: WrapperWithState } - ); - expect(spy).toBeCalledWith( - expect.objectContaining({ index: SYNTHETICS_INDEX_PATTERN }), - expect.anything(), - expect.anything() - ); - }); -}); - -describe('getTimeRangeFilter', () => { - it.each([ - [1, 'now-1h'], - [3, 'now-3h'], - [5, 'now-5h'], - [10, 'now-9h'], - [60, 'now-50h'], - [120, 'now-100h'], - [240, 'now-200h'], - ])('returns expected filter', (val, res) => { - const filter = getTimeRangeFilter(String(val)); - expect(filter).toEqual({ - range: { - '@timestamp': { - gte: res, - lte: 'now', - }, - }, - }); - }); -}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts deleted file mode 100644 index 7b155f53272dd1..00000000000000 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_last_x_checks.ts +++ /dev/null @@ -1,97 +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 { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { createEsParams } from '@kbn/observability-shared-plugin/public'; -import { useReduxEsSearch } from './use_redux_es_search'; -import { Ping } from '../../../../common/runtime_types'; - -import { - EXCLUDE_RUN_ONCE_FILTER, - getLocationFilter, - FINAL_SUMMARY_FILTER, -} from '../../../../common/constants/client_defaults'; -import { selectServiceLocationsState } from '../state'; -import { useSyntheticsRefreshContext } from '../contexts/synthetics_refresh_context'; -import { SYNTHETICS_INDEX_PATTERN, UNNAMED_LOCATION } from '../../../../common/constants'; - -export const getTimeRangeFilter = (schedule: string) => { - const inMinutes = Number(schedule); - const fiftyChecksInMinutes = inMinutes * 50; - const hours = Math.ceil(fiftyChecksInMinutes / 60); - return { - range: { - '@timestamp': { - gte: `now-${hours}h`, - lte: 'now', - }, - }, - }; -}; - -export function useLastXChecks({ - monitorId, - locationId, - fields = ['*'], - size = 50, - timestamp, - schedule, -}: { - monitorId: string; - schedule: string; - locationId: string; - timestamp?: string; - fields?: string[]; - size?: number; -}) { - const { lastRefresh } = useSyntheticsRefreshContext(); - const { locationsLoaded, locations } = useSelector(selectServiceLocationsState); - - const params = createEsParams({ - index: SYNTHETICS_INDEX_PATTERN, - body: { - size, - query: { - bool: { - filter: [ - FINAL_SUMMARY_FILTER, - EXCLUDE_RUN_ONCE_FILTER, - getTimeRangeFilter(schedule), - { - term: { - 'monitor.id': monitorId, - }, - }, - ], - ...getLocationFilter({ - locationId, - locationName: - locations.find((location) => location.id === locationId)?.label || UNNAMED_LOCATION, - }), - }, - }, - _source: false, - sort: [{ '@timestamp': 'desc' }], - fields, - }, - }); - - const { data } = useReduxEsSearch(params, [lastRefresh], { - name: `zGetLastXChecks/${monitorId}/${locationId}`, - isRequestReady: locationsLoaded && Boolean(timestamp), // don't run query until locations are loaded - }); - - const dataAsJSON = JSON.stringify(data?.hits?.hits); - - return useMemo(() => { - return { - hits: (data?.hits?.hits.map((hit) => hit.fields) as Fields[]) || [], - loading: !data, - }; - }, [dataAsJSON]); // eslint-disable-line react-hooks/exhaustive-deps -} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitors_sorted_by_status.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitors_sorted_by_status.tsx index 73f96447493510..c0a59c31a87604 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitors_sorted_by_status.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitors_sorted_by_status.tsx @@ -12,7 +12,10 @@ import { MonitorOverviewItem } from '../../../../common/runtime_types'; import { selectOverviewState } from '../state/overview'; import { useGetUrlParams } from './use_url_params'; -export function useMonitorsSortedByStatus() { +export function useMonitorsSortedByStatus(): { + monitorsSortedByStatus: MonitorOverviewItem[]; + downMonitors: Record | null; +} { const { statusFilter } = useGetUrlParams(); const { status } = useSelector(selectOverviewStatus); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/actions.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/actions.ts index 73e24e6ece6c90..b5098aaa7cbf65 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/actions.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/actions.ts @@ -5,9 +5,10 @@ * 2.0. */ import { createAction } from '@reduxjs/toolkit'; +import { TrendRequest, TrendTable } from '../../../../../common/types'; import { createAsyncAction } from '../utils/actions'; -import { +import type { MonitorOverviewFlyoutConfig, MonitorOverviewPageState, MonitorOverviewState, @@ -33,3 +34,11 @@ export const quietFetchOverviewAction = createAsyncAction< MonitorOverviewPageState, MonitorOverviewResult >('quietFetchOverviewAction'); + +export const refreshOverviewTrends = createAsyncAction( + 'refreshOverviewTrendStats' +); + +export const trendStatsBatch = createAsyncAction( + 'batchTrendStats' +); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/api.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/api.ts index cc496074332b0c..2a8e7820136519 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/api.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/api.ts @@ -13,8 +13,9 @@ import { OverviewStatus, OverviewStatusCodec, } from '../../../../../common/runtime_types'; +import type { TrendRequest, TrendTable } from '../../../../../common/types'; import { apiService } from '../../../../utils/api_service'; -import { MonitorOverviewPageState } from './models'; +import type { MonitorOverviewPageState } from './models'; function toMonitorOverviewQueryArgs( pageState: MonitorOverviewPageState @@ -58,3 +59,6 @@ export const fetchOverviewStatus = async ( const params = toStatusOverviewQueryArgs(pageState); return apiService.get(SYNTHETICS_API_URLS.OVERVIEW_STATUS, params, OverviewStatusCodec); }; + +export const fetchOverviewTrendStats = async (monitors: TrendRequest[]): Promise => + monitors.length ? apiService.post(SYNTHETICS_API_URLS.OVERVIEW_TRENDS, monitors) : {}; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.test.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.test.ts new file mode 100644 index 00000000000000..ceef406ebbd1b5 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.test.ts @@ -0,0 +1,178 @@ +/* + * 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 sagaHelper from 'redux-saga-testing'; +import { call, put, select } from 'redux-saga/effects'; +import { TrendKey, TrendRequest, TrendTable } from '../../../../../common/types'; +import { TRENDS_CHUNK_SIZE, fetchTrendEffect, refreshTrends } from './effects'; +import { trendStatsBatch } from './actions'; +import { fetchOverviewTrendStats as trendsApi } from './api'; +import { selectOverviewState, selectOverviewTrends } from '.'; + +const TEST_TRENDS_LENGTH = 80; + +const generateTrendRequests = () => { + const ar: TrendRequest[] = []; + for (let i = 0; i < TEST_TRENDS_LENGTH; i++) + ar.push({ configId: `configId${i}`, locationId: 'location', schedule: '3' }); + return ar; +}; + +const responseReducer = (acc: Record, curr: TrendKey) => ({ + ...acc, + [curr.configId + curr.locationId]: null, +}); + +describe('overview effects', () => { + describe('fetchTrendEffect', () => { + const trendRequests = generateTrendRequests(); + const firstChunk = trendRequests.slice( + trendRequests.length - TRENDS_CHUNK_SIZE, + trendRequests.length + ); + const secondChunk = trendRequests.slice(0, TEST_TRENDS_LENGTH - TRENDS_CHUNK_SIZE); + const firstChunkResponse = firstChunk.reduce(responseReducer, {}); + const secondChunkResponse = secondChunk.reduce(responseReducer, {}); + + const it = sagaHelper( + fetchTrendEffect(trendStatsBatch.get(trendRequests)) as IterableIterator + ); + + it('calls the `trendsApi` with the first chunk of trend requests', (callResult) => { + expect(callResult).toEqual(call(trendsApi, firstChunk)); + return firstChunkResponse; + }); + + it('sends trends stats success action', (putResult) => { + expect(putResult).toEqual(put(trendStatsBatch.success(firstChunkResponse))); + }); + + it('calls the api for the second chunk', (callResult) => { + expect(callResult).toEqual(call(trendsApi, secondChunk)); + return secondChunkResponse; + }); + + it('sends trends stats success action', (putResult) => { + expect(putResult).toEqual(put(trendStatsBatch.success(secondChunkResponse))); + }); + + it('terminates', (result) => { + expect(result).toBeUndefined(); + }); + }); + + describe('refreshTrends with no data', () => { + const it = sagaHelper(refreshTrends() as IterableIterator); + + it('selects the trends in the table', (selectResult) => { + expect(selectResult).toEqual(select(selectOverviewTrends)); + return { monitor1: null, monitor2: null, monitor3: null }; + }); + + it('selects the overview state', (selectResult) => { + expect(selectResult).toEqual(select(selectOverviewState)); + return { data: { monitors: [] } }; + }); + + it('skips the API if the data is null', (result) => { + expect(result).toBeUndefined(); + }); + }); + + describe('refreshTrends with data', () => { + const it = sagaHelper(refreshTrends() as IterableIterator); + const table: TrendTable = { + monitor1: { + configId: 'monitor1', + locationId: 'location', + data: [{ x: 0, y: 1 }], + count: 1, + median: 1, + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + monitor2: null, + monitor3: { + configId: 'monitor3', + locationId: 'location', + data: [{ x: 0, y: 1 }], + count: 1, + median: 1, + min: 0, + max: 0, + avg: 0, + sum: 0, + }, + }; + + const apiResponse: TrendTable = { + monitor1: { + configId: 'monitor1', + locationId: 'location', + data: [ + { x: 0, y: 1 }, + { x: 1, y: 2 }, + ], + count: 2, + median: 2, + min: 1, + max: 1, + avg: 1, + sum: 1, + }, + monitor2: { + configId: 'monitor2', + locationId: 'location', + data: [ + { x: 0, y: 1 }, + { x: 1, y: 2 }, + ], + count: 2, + median: 2, + min: 1, + max: 1, + avg: 1, + sum: 1, + }, + }; + + it('selects the trends in the table', (selectResult) => { + expect(selectResult).toEqual(select(selectOverviewTrends)); + + return table; + }); + + it('selects the overview state', (selectResults) => { + expect(selectResults).toEqual(select(selectOverviewState)); + return { + data: { + monitors: [ + { configId: 'monitor1', schedule: '3' }, + { configId: 'monitor3', schedule: '3' }, + ], + }, + }; + }); + + it('calls the api for the first chunk', (callResult) => { + expect(callResult).toEqual( + call(trendsApi, [ + { configId: 'monitor1', locationId: 'location', schedule: '3' }, + { configId: 'monitor3', locationId: 'location', schedule: '3' }, + ]) + ); + + return apiResponse; + }); + + it('sends trends stats success action', (putResult) => { + expect(putResult).toEqual(put(trendStatsBatch.success(apiResponse))); + }); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.ts index 71821c46665c77..930c729bde4f70 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/effects.ts @@ -5,10 +5,18 @@ * 2.0. */ -import { debounce } from 'redux-saga/effects'; +import { debounce, call, takeLeading, takeEvery, put, select } from 'redux-saga/effects'; +import type { TrendTable } from '../../../../../common/types'; import { fetchEffectFactory } from '../utils/fetch_effect'; -import { fetchMonitorOverviewAction, quietFetchOverviewAction } from './actions'; -import { fetchMonitorOverview } from './api'; +import { selectOverviewState, selectOverviewTrends } from './selectors'; +import { + fetchMonitorOverviewAction, + quietFetchOverviewAction, + refreshOverviewTrends, + trendStatsBatch, +} from './actions'; +import { fetchMonitorOverview, fetchOverviewTrendStats as trendsApi } from './api'; +import { MonitorOverviewState } from '.'; export function* fetchMonitorOverviewEffect() { yield debounce( @@ -21,3 +29,63 @@ export function* fetchMonitorOverviewEffect() { ) ); } + +export const TRENDS_CHUNK_SIZE = 50; + +export function* fetchTrendEffect( + action: ReturnType +): Generator { + try { + // batch requests LIFO as the user scrolls + for (let i = action.payload.length; i > 0; i -= TRENDS_CHUNK_SIZE) { + const chunk = action.payload.slice(Math.max(i - TRENDS_CHUNK_SIZE, 0), i); + if (chunk.length > 0) { + const trendStats = yield call(trendsApi, chunk); + yield put(trendStatsBatch.success(trendStats)); + } + } + } catch (e: any) { + yield put(trendStatsBatch.fail(e)); + } +} + +export function* fetchOverviewTrendStats() { + yield takeEvery(trendStatsBatch.get, fetchTrendEffect); +} + +export function* refreshTrends(): Generator { + const existingTrends: TrendTable = yield select(selectOverviewTrends); + const overviewState: MonitorOverviewState = yield select(selectOverviewState); + + let acc = {}; + const keys = Object.keys(existingTrends); + while (keys.length) { + const chunk = keys + .splice(0, keys.length < 10 ? keys.length : 40) + .filter( + (key: string) => + existingTrends[key] !== null && + overviewState.data.monitors.some( + ({ configId }) => configId === existingTrends[key]!.configId + ) + ) + .map((key: string) => ({ + configId: existingTrends[key]!.configId, + locationId: existingTrends[key]!.locationId, + schedule: overviewState.data.monitors.find( + ({ configId }) => configId === existingTrends[key]!.configId + )!.schedule, + })); + if (chunk.length) { + const res = yield call(trendsApi, chunk); + acc = { ...acc, ...res }; + } + } + if (Object.keys(acc).length) { + yield put(trendStatsBatch.success(acc)); + } +} + +export function* refreshOverviewTrendStats() { + yield takeLeading(refreshOverviewTrends.get, refreshTrends); +} diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/index.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/index.ts index 665a92aeaf7738..74ad8bb7a2a575 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/index.ts @@ -16,6 +16,7 @@ import { setOverviewGroupByAction, setOverviewPageStateAction, toggleErrorPopoverOpen, + trendStatsBatch, } from './actions'; import { enableMonitorAlertAction } from '../monitor_list/actions'; import { ConfigKey } from '../../components/monitor_add_edit/types'; @@ -31,6 +32,7 @@ const initialState: MonitorOverviewState = { sortOrder: 'asc', sortField: 'status', }, + trendStats: {}, groupBy: { field: 'none', order: 'asc' }, flyoutConfig: null, loading: false, @@ -93,6 +95,18 @@ export const monitorOverviewReducer = createReducer(initialState, (builder) => { }) .addCase(toggleErrorPopoverOpen, (state, action) => { state.isErrorPopoverOpen = action.payload; + }) + .addCase(trendStatsBatch.get, (state, action) => { + for (const { configId, locationId } of action.payload) { + if (!state.trendStats[configId + locationId]) { + state.trendStats[configId + locationId] = null; + } + } + }) + .addCase(trendStatsBatch.success, (state, action) => { + for (const key of Object.keys(action.payload)) { + state.trendStats[key] = action.payload[key]; + } }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts index 8706ca519d49f2..0dbc2100c2fee6 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/models.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +import type { TrendTable } from '../../../../../common/types'; import type { MonitorListSortField } from '../../../../../common/runtime_types/monitor_management/sort_field'; import { ConfigKey, MonitorOverviewResult } from '../../../../../common/runtime_types'; @@ -32,6 +34,7 @@ export interface MonitorOverviewState { isErrorPopoverOpen?: string | null; error: IHttpSerializedFetchError | null; groupBy: GroupByState; + trendStats: TrendTable; } export interface GroupByState { diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/selectors.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/selectors.ts index 677b7cc8208d91..98286a3da118f5 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/selectors.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/overview/selectors.ts @@ -16,3 +16,7 @@ export const selectErrorPopoverState = createSelector( selectOverviewState, (state) => state.isErrorPopoverOpen ); +export const selectOverviewTrends = createSelector( + selectOverviewState, + ({ trendStats }) => trendStats +); diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts index 62d671d8e98fd7..424c6fa70eed67 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/state/root_effect.ts @@ -33,7 +33,11 @@ import { upsertMonitorEffect, fetchMonitorFiltersEffect, } from './monitor_list'; -import { fetchMonitorOverviewEffect } from './overview'; +import { + fetchMonitorOverviewEffect, + fetchOverviewTrendStats, + refreshOverviewTrendStats, +} from './overview'; import { fetchServiceLocationsEffect } from './service_locations'; import { browserJourneyEffects, fetchJourneyStepsEffect } from './browser_journey'; import { fetchPingStatusesEffect } from './ping_status'; @@ -71,5 +75,7 @@ export const rootEffect = function* root(): Generator { fork(getCertsListEffect), fork(getDefaultAlertingEffect), fork(enableDefaultAlertingSilentlyEffect), + fork(fetchOverviewTrendStats), + fork(refreshOverviewTrendStats), ]); }; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index abfc08919b33ad..897be8c4ad9707 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -100,6 +100,7 @@ export const mockState: SyntheticsAppState = { sortOrder: 'asc', sortField: 'name.keyword', }, + trendStats: {}, data: { total: 0, allMonitorIds: [], diff --git a/x-pack/plugins/observability_solution/synthetics/server/lib.ts b/x-pack/plugins/observability_solution/synthetics/server/lib.ts index 12f08d679c89e3..63d511a2d2063f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/lib.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/lib.ts @@ -122,8 +122,8 @@ export class SyntheticsEsClient { } async msearch< - TDocument = unknown, - TSearchRequest extends estypes.SearchRequest = estypes.SearchRequest + TSearchRequest extends estypes.SearchRequest = estypes.SearchRequest, + TDocument = unknown >( requests: MsearchMultisearchBody[] ): Promise<{ responses: Array> }> { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts index c97abe44a6c5a6..ba3bc0b443fb93 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/index.ts @@ -60,6 +60,7 @@ import { getAllSyntheticsMonitorRoute } from './monitor_cruds/get_monitors_list' import { getLocationMonitors } from './settings/private_locations/get_location_monitors'; import { addSyntheticsParamsRoute } from './settings/params/add_param'; import { deleteSyntheticsParamsRoute } from './settings/params/delete_param'; +import { createOverviewTrendsRoute } from './overview_trends/overview_trends'; export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ addSyntheticsProjectMonitorRoute, @@ -101,6 +102,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ getConnectorTypesRoute, createGetDynamicSettingsRoute, createPostDynamicSettingsRoute, + createOverviewTrendsRoute, ]; export const syntheticsAppPublicRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/fetch_trends.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/fetch_trends.ts new file mode 100644 index 00000000000000..b57c634c1bddd3 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/fetch_trends.ts @@ -0,0 +1,92 @@ +/* + * 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 { EXCLUDE_RUN_ONCE_FILTER, SUMMARY_FILTER } from '../../../common/constants/client_defaults'; +import { createEsParams } from '../../lib'; + +export const getFetchTrendsQuery = (configId: string, locationIds: string[], interval: number) => + createEsParams({ + body: { + size: 0, + query: { + bool: { + filter: [ + SUMMARY_FILTER, + EXCLUDE_RUN_ONCE_FILTER, + { + terms: { + 'observer.name': locationIds, + }, + }, + { + term: { + config_id: configId, + }, + }, + { + range: { + '@timestamp': { + gte: `now-${interval}m`, + lte: 'now', + }, + }, + }, + ], + }, + }, + aggs: { + byId: { + terms: { + field: 'config_id', + }, + aggs: { + byLocation: { + terms: { + field: 'observer.name', + }, + aggs: { + last50: { + histogram: { + field: '@timestamp', + interval: interval * 1000, + min_doc_count: 1, + }, + aggs: { + max: { + avg: { + field: 'monitor.duration.us', + }, + }, + }, + }, + stats: { + stats: { + field: 'monitor.duration.us', + }, + }, + median: { + percentiles: { + field: 'monitor.duration.us', + percents: [50], + }, + }, + }, + }, + }, + }, + }, + _source: false, + sort: [ + { + '@timestamp': 'desc', + }, + ], + fields: ['monitor.duration.us'], + }, + }); + +export type TrendsQuery = ReturnType; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts new file mode 100644 index 00000000000000..e3d7589160c027 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/overview_trends/overview_trends.ts @@ -0,0 +1,74 @@ +/* + * 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 { ObjectType, schema } from '@kbn/config-schema'; +import { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { TrendRequest, TrendTable } from '../../../common/types'; +import { getFetchTrendsQuery, TrendsQuery } from './fetch_trends'; +import { SyntheticsRestApiRouteFactory } from '../types'; + +export const getIntervalForCheckCount = (schedule: string, numChecks = 50) => + Number(schedule) * numChecks; + +export const createOverviewTrendsRoute: SyntheticsRestApiRouteFactory = () => ({ + method: 'POST', + path: SYNTHETICS_API_URLS.OVERVIEW_TRENDS, + validate: { + body: schema.arrayOf( + schema.object({ + configId: schema.string(), + locationId: schema.string(), + schedule: schema.string(), + }) + ) as unknown as ObjectType, + }, + handler: async (routeContext): Promise => { + const esClient = routeContext.syntheticsEsClient; + const body = routeContext.request.body as TrendRequest[]; + + const configs = body.reduce( + ( + acc: Record, + { configId, locationId, schedule } + ) => { + if (!acc[configId]) { + acc[configId] = { locations: [locationId], interval: getIntervalForCheckCount(schedule) }; + } else { + acc[configId].locations.push(locationId); + } + return acc; + }, + {} + ); + + const requests = Object.keys(configs).map( + (key) => getFetchTrendsQuery(key, configs[key].locations, configs[key].interval).body + ); + const results = await esClient.msearch(requests); + + let main = {}; + for (const res of results.responses) { + res.aggregations?.byId.buckets.map(({ key, byLocation }) => { + const ret: Record = {}; + for (const location of byLocation.buckets) { + ret[String(key) + String(location.key)] = { + configId: key, + locationId: location.key, + data: location.last50.buckets.map((durationBucket, x) => ({ + x, + y: durationBucket.max.value, + })), + ...location.stats, + median: location.median.values['50.0'], + }; + } + main = { ...main, ...ret }; + }); + } + return main; + }, +}); diff --git a/yarn.lock b/yarn.lock index 383addbf0098be..dc184796bc2419 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11174,7 +11174,15 @@ "@types/prop-types" "*" "@types/react" "*" -"@types/react-window@^1.8.8": +"@types/react-window-infinite-loader@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz#9b24d4e60f20397ff853c6857f7fe0645becbeb9" + integrity sha512-gEInTjQwURCnDOFyIEK2+fWB5gTjqwx30O62QfxA9stE5aiB6EWkGj4UMhc0axq7/FV++Gs/TGW8FtgEx0S6Tw== + dependencies: + "@types/react" "*" + "@types/react-window" "*" + +"@types/react-window@*", "@types/react-window@^1.8.8": version "1.8.8" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.8.tgz#c20645414d142364fbe735818e1c1e0a145696e3" integrity sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q== @@ -26949,6 +26957,11 @@ react-virtualized@^9.22.5: prop-types "^15.7.2" react-lifecycles-compat "^3.0.4" +react-window-infinite-loader@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz#d861c03d5cbc550e2f185371af820fd22d46c099" + integrity sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw== + react-window@^1.8.10: version "1.8.10" resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.10.tgz#9e6b08548316814b443f7002b1cf8fd3a1bdde03" @@ -27212,6 +27225,11 @@ redux-devtools-extension@^2.13.8: resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== +redux-saga-testing@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/redux-saga-testing/-/redux-saga-testing-2.0.2.tgz#a542b771a6b6584397198f35d47d07bae8dbfc9c" + integrity sha512-8IVPTaEw0Typ9TGCAsktFrrU+I5ACbmwPmzW0DQjUgZvja0k1jP2ILnUn+fZDF1gT8c0L/Ubj//bsej5X3OkaQ== + redux-saga@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"