From ac73b6a5b4504548f8d19909767e87515a69fb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 23 Nov 2020 11:58:49 +0100 Subject: [PATCH] [Observability] Load hasData call asynchronously (#80644) * obs perf * fixing unit tests * fixing ts issues * fixing empty state * addressing pr comments * addressing pr comments * fixing TS issue * fixing some stuff * refactoring * fixing ts issues and unit tests * addressing PR comments * fixing TS issues * fixing eslint issue Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../app/RumDashboard/ux_overview_fetchers.ts | 5 +- .../apm/server/lib/rum_client/has_rum_data.ts | 2 +- .../public/utils/logs_overview_fetchers.ts | 11 +- .../public/application/application.test.tsx | 16 + .../public/application/index.tsx | 9 +- .../empty_section.test.tsx} | 2 +- .../empty_section.tsx} | 0 .../components/app/empty_sections/index.tsx | 64 +++ .../components/app/section/apm/index.test.tsx | 67 ++- .../components/app/section/apm/index.tsx | 43 +- .../components/app/section/logs/index.tsx | 49 +- .../components/app/section/metrics/index.tsx | 36 +- .../components/app/section/uptime/index.tsx | 43 +- .../components/app/section/ux/index.test.tsx | 80 +-- .../components/app/section/ux/index.tsx | 42 +- .../components/shared/date_picker/index.tsx | 3 + .../public/context/has_data_context.test.tsx | 467 ++++++++++++++++++ .../public/context/has_data_context.tsx | 125 +++++ .../observability/public/data_handler.test.ts | 213 +------- .../observability/public/data_handler.ts | 43 +- .../public/hooks/use_has_data.ts | 12 + .../public/hooks/use_route_params.tsx | 13 +- .../public/hooks/use_time_range.test.ts | 94 ++++ .../public/hooks/use_time_range.ts | 40 ++ .../public/pages/home/index.test.tsx | 55 +++ .../observability/public/pages/home/index.tsx | 23 +- .../public/pages/overview/data_sections.tsx | 77 +-- .../public/pages/overview/index.tsx | 108 +--- .../pages/overview/overview.stories.tsx | 5 +- .../services/get_observability_alerts.test.ts | 68 +-- .../services/get_observability_alerts.ts | 19 +- .../typings/fetch_overview_data/index.ts | 16 +- 32 files changed, 1212 insertions(+), 638 deletions(-) rename x-pack/plugins/observability/public/components/app/{empty_section/index.test.tsx => empty_sections/empty_section.test.tsx} (96%) rename x-pack/plugins/observability/public/components/app/{empty_section/index.tsx => empty_sections/empty_section.tsx} (100%) create mode 100644 x-pack/plugins/observability/public/components/app/empty_sections/index.tsx create mode 100644 x-pack/plugins/observability/public/context/has_data_context.test.tsx create mode 100644 x-pack/plugins/observability/public/context/has_data_context.tsx create mode 100644 x-pack/plugins/observability/public/hooks/use_has_data.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_time_range.test.ts create mode 100644 x-pack/plugins/observability/public/hooks/use_time_range.ts create mode 100644 x-pack/plugins/observability/public/pages/home/index.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts index 4610205cee7e..7ce9d3f25354 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ux_overview_fetchers.ts @@ -8,6 +8,7 @@ import { FetchDataParams, HasDataParams, UxFetchDataResponse, + UXHasDataResponse, } from '../../../../../observability/public/'; import { callApmApi } from '../../../services/rest/createCallApmApi'; @@ -35,7 +36,9 @@ export const fetchUxOverviewDate = async ({ }; }; -export async function hasRumData({ absoluteTime }: HasDataParams) { +export async function hasRumData({ + absoluteTime, +}: HasDataParams): Promise { return await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_rum_data', params: { diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts index 14245ce1d6c8..bcd6d10d3198 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -54,6 +54,6 @@ export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key, }; } catch (e) { - return false; + return { hasData: false, serviceName: undefined }; } } diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 9ca6db40a305..32812f19a254 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -6,12 +6,7 @@ import { encode } from 'rison-node'; import { SearchResponse } from 'elasticsearch'; -import { - FetchData, - FetchDataParams, - HasData, - LogsFetchDataResponse, -} from '../../../observability/public'; +import { FetchData, FetchDataParams, LogsFetchDataResponse } from '../../../observability/public'; import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; @@ -38,9 +33,7 @@ interface LogParams { type StatsAndSeries = Pick; -export function getLogsHasDataFetcher( - getStartServices: InfraClientCoreSetup['getStartServices'] -): HasData { +export function getLogsHasDataFetcher(getStartServices: InfraClientCoreSetup['getStartServices']) { return async () => { const [core] = await getStartServices(); const sourceStatus = await callFetchLogSourceStatusAPI(DEFAULT_SOURCE_ID, core.http.fetch); diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index dfe7280b717a..2c08354c9111 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -11,9 +11,25 @@ import { ObservabilityPluginSetupDeps } from '../plugin'; import { renderApp } from './'; describe('renderApp', () => { + const originalConsole = global.console; + beforeAll(() => { + // mocks console to avoid poluting the test output + global.console = ({ error: jest.fn() } as unknown) as typeof console; + }); + + afterAll(() => { + global.console = originalConsole; + }); it('renders', async () => { const plugins = ({ usageCollection: { reportUiStats: () => {} }, + data: { + query: { + timefilter: { + timefilter: { setTime: jest.fn(), getTime: jest.fn().mockImplementation(() => ({})) }, + }, + }, + }, } as unknown) as ObservabilityPluginSetupDeps; const core = ({ application: { currentAppId$: new Observable(), navigateToUrl: () => {} }, diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 585a45cf5279..ea84a417c20e 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -17,6 +17,7 @@ import { PluginContext } from '../context/plugin_context'; import { usePluginContext } from '../hooks/use_plugin_context'; import { useRouteParams } from '../hooks/use_route_params'; import { ObservabilityPluginSetupDeps } from '../plugin'; +import { HasDataContextProvider } from '../context/has_data_context'; import { Breadcrumbs, routes } from '../routes'; const observabilityLabelBreadcrumb = { @@ -46,8 +47,8 @@ function App() { core.chrome.docTitle.change(getTitleFromBreadCrumbs(breadcrumb)); }, [core, breadcrumb]); - const { query, path: pathParams } = useRouteParams(route.params); - return route.handler({ query, path: pathParams }); + const params = useRouteParams(path); + return route.handler(params); }; return ; })} @@ -79,7 +80,9 @@ export const renderApp = ( - + + + diff --git a/x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx similarity index 96% rename from x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx rename to x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx index 6a05749df6d7..22867dde83a0 100644 --- a/x-pack/plugins/observability/public/components/app/empty_section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ISection } from '../../../typings/section'; import { render } from '../../../utils/test_helper'; -import { EmptySection } from './'; +import { EmptySection } from './empty_section'; describe('EmptySection', () => { it('renders without action button', () => { diff --git a/x-pack/plugins/observability/public/components/app/empty_section/index.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/empty_section.tsx similarity index 100% rename from x-pack/plugins/observability/public/components/app/empty_section/index.tsx rename to x-pack/plugins/observability/public/components/app/empty_sections/empty_section.tsx diff --git a/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx b/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx new file mode 100644 index 000000000000..34522ef95e27 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/empty_sections/index.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiFlexGrid, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useContext } from 'react'; +import { ThemeContext } from 'styled-components'; +import { Alert } from '../../../../../alerts/common'; +import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { useHasData } from '../../../hooks/use_has_data'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; +import { getEmptySections } from '../../../pages/overview/empty_section'; +import { UXHasDataResponse } from '../../../typings'; +import { EmptySection } from './empty_section'; + +export function EmptySections() { + const { core } = usePluginContext(); + const theme = useContext(ThemeContext); + const { hasData } = useHasData(); + + const appEmptySections = getEmptySections({ core }).filter(({ id }) => { + if (id === 'alert') { + const { status, hasData: alerts } = hasData.alert || {}; + return ( + status === FETCH_STATUS.FAILURE || + (status === FETCH_STATUS.SUCCESS && (alerts as Alert[]).length === 0) + ); + } else { + const app = hasData[id]; + if (app) { + const _hasData = id === 'ux' ? (app.hasData as UXHasDataResponse)?.hasData : app.hasData; + return app.status === FETCH_STATUS.FAILURE || !_hasData; + } + } + return false; + }); + return ( + + + 2 ? 2 : 1 + } + gutterSize="s" + > + {appEmptySections.map((app) => { + return ( + + + + ); + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index 7b9d7276dd1c..9fdc59d61257 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -8,25 +8,59 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; -import moment from 'moment'; +import * as hasDataHook from '../../../../hooks/use_has_data'; +import * as pluginContext from '../../../../hooks/use_plugin_context'; +import { HasDataContextValue } from '../../../../context/has_data_context'; +import { AppMountParameters, CoreStart } from 'kibana/public'; +import { ObservabilityPluginSetupDeps } from '../../../../plugin'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), + useHistory: jest.fn(), +})); describe('APMSection', () => { + beforeAll(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasData: { + apm: { + status: fetcherHook.FETCH_STATUS.SUCCESS, + hasData: true, + }, + }, + } as HasDataContextValue); + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: ({ + uiSettings: { get: jest.fn() }, + http: { basePath: { prepend: jest.fn() } }, + } as unknown) as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); it('renders with transaction series and stats', () => { jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ data: response, status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, queryAllByTestId } = render( - - ); + const { getByText, queryAllByTestId } = render(); expect(getByText('APM')).toBeInTheDocument(); expect(getByText('View in app')).toBeInTheDocument(); @@ -40,16 +74,7 @@ describe('APMSection', () => { status: fetcherHook.FETCH_STATUS.LOADING, refetch: jest.fn(), }); - const { getByText, queryAllByText, getByTestId } = render( - - ); + const { getByText, queryAllByText, getByTestId } = render(); expect(getByText('APM')).toBeInTheDocument(); expect(getByTestId('loading')).toBeInTheDocument(); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index b635c2c68b92..91d20d347896 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -12,17 +12,17 @@ import moment from 'moment'; import React, { useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { ThemeContext } from 'styled-components'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,25 +30,36 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export function APMSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function APMSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const chartTheme = useChartTheme(); const history = useHistory(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('apm')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.apm?.hasData) { + return null; + } const { appLink, stats, series } = data || {}; - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -93,7 +104,7 @@ export function APMSection({ absoluteTime, relativeTime, bucketSize }: Props) { onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend={false} xDomain={{ min, max }} /> diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index 343611294bc4..f60cab86453d 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -5,19 +5,19 @@ */ import { Axis, BarSeries, niceTimeFormatter, Position, ScaleType, Settings } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, euiPaletteColorBlind, EuiSpacer, EuiTitle } from '@elastic/eui'; import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import moment from 'moment'; import React, { Fragment } from 'react'; import { useHistory } from 'react-router-dom'; -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer } from '@elastic/eui'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { LogsFetchDataResponse } from '../../../../typings'; import { formatStatValue } from '../../../../utils/format_stat_value'; import { ChartContainer } from '../../chart_container'; @@ -25,8 +25,6 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,22 +43,33 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export function LogsSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function LogsSection({ bucketSize }: Props) { const history = useHistory(); + const chartTheme = useChartTheme(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + if (!hasData.infra_logs?.hasData) { + return null; + } + + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -115,7 +124,7 @@ export function LogsSection({ absoluteTime, relativeTime, bucketSize }: Props) { onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend legendPosition={Position.Right} xDomain={{ min, max }} diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 8bce8205902f..f7fe3f5694a4 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -13,13 +13,13 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { Series } from '../../../../typings'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,19 +46,29 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export function MetricsSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function MetricsSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); + + if (!hasData.infra_metrics?.hasData) { + return null; + } const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 879d745ff2b6..b0710a5c695a 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -24,34 +24,45 @@ import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { useChartTheme } from '../../../../hooks/use_chart_theme'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; import { Series } from '../../../../typings'; import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; bucketSize?: string; } -export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) { +export function UptimeSection({ bucketSize }: Props) { const theme = useContext(ThemeContext); + const chartTheme = useChartTheme(); const history = useHistory(); + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); - const { start, end } = absoluteTime; - const { data, status } = useFetcher(() => { - if (start && end && bucketSize) { - return getDataHandler('uptime')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - bucketSize, - }); - } - }, [start, end, bucketSize, relativeTime]); + const { data, status } = useFetcher( + () => { + if (bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate] + ); - const min = moment.utc(absoluteTime.start).valueOf(); - const max = moment.utc(absoluteTime.end).valueOf(); + if (!hasData.uptime?.hasData) { + return null; + } + + const min = moment.utc(absoluteStart).valueOf(); + const max = moment.utc(absoluteEnd).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -112,7 +123,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props) onBrushEnd({ x, history })} - theme={useChartTheme()} + theme={chartTheme} showLegend={false} legendPosition={Position.Right} xDomain={{ min, max }} diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx index ef1820eaaeb3..be6df5516638 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.test.tsx @@ -3,31 +3,63 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; -import moment from 'moment'; +import { HasDataContextValue } from '../../../../context/has_data_context'; import * as fetcherHook from '../../../../hooks/use_fetcher'; +import * as hasDataHook from '../../../../hooks/use_has_data'; +import * as pluginContext from '../../../../hooks/use_plugin_context'; +import { ObservabilityPluginSetupDeps } from '../../../../plugin'; import { render } from '../../../../utils/test_helper'; import { UXSection } from './'; import { response } from './mock_data/ux.mock'; +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), +})); + describe('UXSection', () => { + beforeAll(() => { + jest.spyOn(hasDataHook, 'useHasData').mockReturnValue({ + hasData: { + ux: { + status: fetcherHook.FETCH_STATUS.SUCCESS, + hasData: { hasData: true, serviceName: 'elastic-co-frontend' }, + }, + }, + } as HasDataContextValue); + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: ({ + uiSettings: { get: jest.fn() }, + http: { basePath: { prepend: jest.fn() } }, + } as unknown) as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); it('renders with core web vitals', () => { jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ data: response, status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, getAllByText } = render( - - ); + const { getByText, getAllByText } = render(); expect(getByText('User Experience')).toBeInTheDocument(); expect(getByText('View in app')).toBeInTheDocument(); @@ -59,17 +91,7 @@ describe('UXSection', () => { status: fetcherHook.FETCH_STATUS.LOADING, refetch: jest.fn(), }); - const { getByText, queryAllByText, getAllByText } = render( - - ); + const { getByText, queryAllByText, getAllByText } = render(); expect(getByText('User Experience')).toBeInTheDocument(); expect(getAllByText('--')).toHaveLength(3); @@ -82,17 +104,7 @@ describe('UXSection', () => { status: fetcherHook.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); - const { getByText, queryAllByText, getAllByText } = render( - - ); + const { getByText, queryAllByText, getAllByText } = render(); expect(getByText('User Experience')).toBeInTheDocument(); expect(getAllByText('No data is available.')).toHaveLength(3); diff --git a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx index 0c40ce0bf7a2..43f1072d06fc 100644 --- a/x-pack/plugins/observability/public/components/app/section/ux/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/ux/index.tsx @@ -9,28 +9,40 @@ import React from 'react'; import { SectionContainer } from '../'; import { getDataHandler } from '../../../../data_handler'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; +import { useHasData } from '../../../../hooks/use_has_data'; +import { useTimeRange } from '../../../../hooks/use_time_range'; +import { UXHasDataResponse } from '../../../../typings'; import { CoreVitals } from '../../../shared/core_web_vitals'; interface Props { - serviceName: string; bucketSize: string; - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; } -export function UXSection({ serviceName, bucketSize, absoluteTime, relativeTime }: Props) { - const { start, end } = absoluteTime; +export function UXSection({ bucketSize }: Props) { + const { forceUpdate, hasData } = useHasData(); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + const uxHasDataResponse = (hasData.ux?.hasData as UXHasDataResponse) || {}; + const serviceName = uxHasDataResponse.serviceName as string; - const { data, status } = useFetcher(() => { - if (start && end) { - return getDataHandler('ux')?.fetchData({ - absoluteTime: { start, end }, - relativeTime, - serviceName, - bucketSize, - }); - } - }, [start, end, relativeTime, serviceName, bucketSize]); + const { data, status } = useFetcher( + () => { + if (serviceName && bucketSize) { + return getDataHandler('ux')?.fetchData({ + absoluteTime: { start: absoluteStart, end: absoluteEnd }, + relativeTime: { start: relativeStart, end: relativeEnd }, + serviceName, + bucketSize, + }); + } + }, + // Absolute times shouldn't be used here, since it would refetch on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + [bucketSize, relativeStart, relativeEnd, forceUpdate, serviceName] + ); + + if (!uxHasDataResponse?.hasData) { + return null; + } const isLoading = status === FETCH_STATUS.LOADING; diff --git a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx index 747ec8a441c4..32c6c6054f77 100644 --- a/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/date_picker/index.tsx @@ -7,6 +7,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import React, { useEffect } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import { useHasData } from '../../../hooks/use_has_data'; import { UI_SETTINGS, useKibanaUISettings } from '../../../hooks/use_kibana_ui_settings'; import { usePluginContext } from '../../../hooks/use_plugin_context'; import { fromQuery, toQuery } from '../../../utils/url'; @@ -36,6 +37,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval const location = useLocation(); const history = useHistory(); const { plugins } = usePluginContext(); + const { onRefreshTimeRange } = useHasData(); useEffect(() => { plugins.data.query.timefilter.timefilter.setTime({ @@ -81,6 +83,7 @@ export function DatePicker({ rangeFrom, rangeTo, refreshPaused, refreshInterval function onTimeChange({ start, end }: { start: string; end: string }) { updateUrl({ rangeFrom: start, rangeTo: end }); + onRefreshTimeRange(); } return ( diff --git a/x-pack/plugins/observability/public/context/has_data_context.test.tsx b/x-pack/plugins/observability/public/context/has_data_context.test.tsx new file mode 100644 index 000000000000..3369765c68bd --- /dev/null +++ b/x-pack/plugins/observability/public/context/has_data_context.test.tsx @@ -0,0 +1,467 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// import { act, getByText } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { CoreStart } from 'kibana/public'; +import React from 'react'; +import { registerDataHandler, unregisterDataHandler } from '../data_handler'; +import { useHasData } from '../hooks/use_has_data'; +import * as routeParams from '../hooks/use_route_params'; +import * as timeRange from '../hooks/use_time_range'; +import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overview_data'; +import { HasDataContextProvider } from './has_data_context'; +import * as pluginContext from '../hooks/use_plugin_context'; +import { PluginContextValue } from './plugin_context'; + +const relativeStart = '2020-10-08T06:00:00.000Z'; +const relativeEnd = '2020-10-08T07:00:00.000Z'; + +function wrapper({ children }: { children: React.ReactElement }) { + return {children}; +} + +function unregisterAll() { + unregisterDataHandler({ appName: 'apm' }); + unregisterDataHandler({ appName: 'infra_logs' }); + unregisterDataHandler({ appName: 'infra_metrics' }); + unregisterDataHandler({ appName: 'uptime' }); + unregisterDataHandler({ appName: 'ux' }); +} + +function registerApps( + apps: Array<{ appName: T; hasData: HasData }> +) { + apps.forEach(({ appName, hasData }) => { + registerDataHandler({ + appName, + fetchData: () => ({} as any), + hasData, + }); + }); +} + +describe('HasDataContextProvider', () => { + beforeAll(() => { + jest.spyOn(routeParams, 'useRouteParams').mockImplementation(() => ({ + query: { + from: relativeStart, + to: relativeEnd, + }, + path: {}, + })); + jest.spyOn(timeRange, 'useTimeRange').mockImplementation(() => ({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + })); + jest.spyOn(pluginContext, 'usePluginContext').mockReturnValue({ + core: ({ http: { get: jest.fn() } } as unknown) as CoreStart, + } as PluginContextValue); + }); + + describe('when no plugin has registered', () => { + it('hasAnyData returns false and all apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toMatchObject({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + describe('when plugins have registered', () => { + describe('all apps return false', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => false }, + { appName: 'infra_logs', hasData: async () => false }, + { appName: 'infra_metrics', hasData: async () => false }, + { appName: 'uptime', hasData: async () => false }, + { appName: 'ux', hasData: async () => ({ hasData: false, serviceName: undefined }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false and all apps return false', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: false, status: 'success' }, + uptime: { hasData: false, status: 'success' }, + infra_logs: { hasData: false, status: 'success' }, + infra_metrics: { hasData: false, status: 'success' }, + ux: { hasData: { hasData: false, serviceName: undefined }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('at least one app returns true', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => true }, + { appName: 'infra_logs', hasData: async () => false }, + { appName: 'infra_metrics', hasData: async () => false }, + { appName: 'uptime', hasData: async () => false }, + { appName: 'ux', hasData: async () => ({ hasData: false, serviceName: undefined }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true apm returns true and all other apps return false', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: false, status: 'success' }, + infra_logs: { hasData: false, status: 'success' }, + infra_metrics: { hasData: false, status: 'success' }, + ux: { hasData: { hasData: false, serviceName: undefined }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('all apps return true', () => { + beforeAll(() => { + registerApps([ + { appName: 'apm', hasData: async () => true }, + { appName: 'infra_logs', hasData: async () => true }, + { appName: 'infra_metrics', hasData: async () => true }, + { appName: 'uptime', hasData: async () => true }, + { appName: 'ux', hasData: async () => ({ hasData: true, serviceName: 'ux' }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true and all apps return true', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: true, status: 'success' }, + infra_logs: { hasData: true, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: { hasData: true, serviceName: 'ux' }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('only apm is registered', () => { + describe('when apm returns true', () => { + beforeAll(() => { + registerApps([{ appName: 'apm', hasData: async () => true }]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true, apm returns true and all other apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { + wrapper, + }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: true, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('when apm returns false', () => { + beforeAll(() => { + registerApps([{ appName: 'apm', hasData: async () => false }]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false, apm returns false and all other apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { + wrapper, + }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: false, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + }); + + describe('when an app throws an error while fetching', () => { + beforeAll(() => { + registerApps([ + { + appName: 'apm', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { appName: 'infra_logs', hasData: async () => true }, + { appName: 'infra_metrics', hasData: async () => true }, + { appName: 'uptime', hasData: async () => true }, + { appName: 'ux', hasData: async () => ({ hasData: true, serviceName: 'ux' }) }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns true, apm is undefined and all other apps return true', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'failure' }, + uptime: { hasData: true, status: 'success' }, + infra_logs: { hasData: true, status: 'success' }, + infra_metrics: { hasData: true, status: 'success' }, + ux: { hasData: { hasData: true, serviceName: 'ux' }, status: 'success' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: true, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + + describe('when all apps throw an error while fetching', () => { + beforeAll(() => { + registerApps([ + { + appName: 'apm', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'infra_logs', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'infra_metrics', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'uptime', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + { + appName: 'ux', + hasData: async () => { + throw new Error('BOOMMMMM'); + }, + }, + ]); + }); + + afterAll(unregisterAll); + + it('hasAnyData returns false and all apps return undefined', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'failure' }, + uptime: { hasData: undefined, status: 'failure' }, + infra_logs: { hasData: undefined, status: 'failure' }, + infra_metrics: { hasData: undefined, status: 'failure' }, + ux: { hasData: undefined, status: 'failure' }, + alert: { hasData: [], status: 'success' }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); + }); + + describe('with alerts', () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockReturnValue({ + core: ({ + http: { + get: async () => { + return { + data: [ + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + ], + }; + }, + }, + } as unknown) as CoreStart, + } as PluginContextValue); + }); + + it('returns all alerts available', async () => { + const { result, waitForNextUpdate } = renderHook(() => useHasData(), { wrapper }); + expect(result.current).toEqual({ + hasData: {}, + hasAnyData: false, + isAllRequestsComplete: false, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + hasData: { + apm: { hasData: undefined, status: 'success' }, + uptime: { hasData: undefined, status: 'success' }, + infra_logs: { hasData: undefined, status: 'success' }, + infra_metrics: { hasData: undefined, status: 'success' }, + ux: { hasData: undefined, status: 'success' }, + alert: { + hasData: [ + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + ], + status: 'success', + }, + }, + hasAnyData: false, + isAllRequestsComplete: true, + forceUpdate: expect.any(String), + onRefreshTimeRange: expect.any(Function), + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx new file mode 100644 index 000000000000..79d58056af73 --- /dev/null +++ b/x-pack/plugins/observability/public/context/has_data_context.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniqueId } from 'lodash'; +import React, { createContext, useEffect, useState } from 'react'; +import { Alert } from '../../../alerts/common'; +import { getDataHandler } from '../data_handler'; +import { FETCH_STATUS } from '../hooks/use_fetcher'; +import { usePluginContext } from '../hooks/use_plugin_context'; +import { useTimeRange } from '../hooks/use_time_range'; +import { getObservabilityAlerts } from '../services/get_observability_alerts'; +import { ObservabilityFetchDataPlugins, UXHasDataResponse } from '../typings/fetch_overview_data'; + +type DataContextApps = ObservabilityFetchDataPlugins | 'alert'; + +export type HasDataMap = Record< + DataContextApps, + { status: FETCH_STATUS; hasData?: boolean | UXHasDataResponse | Alert[] } +>; + +export interface HasDataContextValue { + hasData: Partial; + hasAnyData: boolean; + isAllRequestsComplete: boolean; + onRefreshTimeRange: () => void; + forceUpdate: string; +} + +export const HasDataContext = createContext({} as HasDataContextValue); + +const apps: DataContextApps[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics', 'ux', 'alert']; + +export function HasDataContextProvider({ children }: { children: React.ReactNode }) { + const { core } = usePluginContext(); + const [forceUpdate, setForceUpdate] = useState(''); + const { absoluteStart, absoluteEnd } = useTimeRange(); + + const [hasData, setHasData] = useState({}); + + useEffect( + () => { + apps.forEach(async (app) => { + try { + if (app !== 'alert') { + const params = + app === 'ux' + ? { absoluteTime: { start: absoluteStart, end: absoluteEnd } } + : undefined; + + const result = await getDataHandler(app)?.hasData(params); + setHasData((prevState) => ({ + ...prevState, + [app]: { + hasData: result, + status: FETCH_STATUS.SUCCESS, + }, + })); + } + } catch (e) { + setHasData((prevState) => ({ + ...prevState, + [app]: { + hasData: undefined, + status: FETCH_STATUS.FAILURE, + }, + })); + } + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + async function fetchAlerts() { + try { + const alerts = await getObservabilityAlerts({ core }); + setHasData((prevState) => ({ + ...prevState, + alert: { + hasData: alerts, + status: FETCH_STATUS.SUCCESS, + }, + })); + } catch (e) { + setHasData((prevState) => ({ + ...prevState, + alert: { + hasData: undefined, + status: FETCH_STATUS.FAILURE, + }, + })); + } + } + + fetchAlerts(); + }, [forceUpdate, core]); + + const isAllRequestsComplete = apps.every((app) => { + const appStatus = hasData[app]?.status; + return appStatus !== undefined && appStatus !== FETCH_STATUS.LOADING; + }); + + const hasAnyData = (Object.keys(hasData) as ObservabilityFetchDataPlugins[]).some( + (app) => hasData[app]?.hasData === true + ); + + return ( + { + setForceUpdate(uniqueId()); + }, + }} + children={children} + /> + ); +} diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 8fdfc2bc622c..f555f11be225 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -3,20 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - registerDataHandler, - getDataHandler, - unregisterDataHandler, - fetchHasData, -} from './data_handler'; +import { registerDataHandler, getDataHandler } from './data_handler'; import moment from 'moment'; -import { - ApmFetchDataResponse, - LogsFetchDataResponse, - MetricsFetchDataResponse, - UptimeFetchDataResponse, - UxFetchDataResponse, -} from './typings'; const params = { absoluteTime: { @@ -447,203 +435,4 @@ describe('registerDataHandler', () => { expect(hasData).toBeTruthy(); }); }); - describe('fetchHasData', () => { - it('returns false when an exception happens', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - it('returns true when has data and false when an exception happens', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => { - throw new Error('BOOM'); - }, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: true, - uptime: false, - infra_logs: true, - infra_metrics: false, - ux: false, - }); - }); - it('returns true when has data', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => true, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => ({ - hasData: true, - serviceName: 'elastic-co', - }), - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: true, - uptime: true, - infra_logs: true, - infra_metrics: true, - ux: { - hasData: true, - serviceName: 'elastic-co', - }, - }); - }); - it('returns false when has no data', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - registerDataHandler({ - appName: 'apm', - fetchData: async () => (({} as unknown) as ApmFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'infra_logs', - fetchData: async () => (({} as unknown) as LogsFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'infra_metrics', - fetchData: async () => (({} as unknown) as MetricsFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'uptime', - fetchData: async () => (({} as unknown) as UptimeFetchDataResponse), - hasData: async () => false, - }); - registerDataHandler({ - appName: 'ux', - fetchData: async () => (({} as unknown) as UxFetchDataResponse), - hasData: async () => false, - }); - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - it('returns false when has data was not registered', async () => { - unregisterDataHandler({ appName: 'apm' }); - unregisterDataHandler({ appName: 'infra_logs' }); - unregisterDataHandler({ appName: 'infra_metrics' }); - unregisterDataHandler({ appName: 'uptime' }); - unregisterDataHandler({ appName: 'ux' }); - - expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({ - apm: false, - uptime: false, - infra_logs: false, - infra_metrics: false, - ux: false, - }); - }); - }); }); diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index 91043a3da0da..7ee7db7ede17 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - DataHandler, - HasDataResponse, - ObservabilityFetchDataPlugins, -} from './typings/fetch_overview_data'; +import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data'; const dataHandlers: Partial> = {}; @@ -34,40 +30,3 @@ export function getDataHandler(appName: return dataHandler as DataHandler; } } - -export async function fetchHasData(absoluteTime: { - start: number; - end: number; -}): Promise> { - const apps: ObservabilityFetchDataPlugins[] = [ - 'apm', - 'uptime', - 'infra_logs', - 'infra_metrics', - 'ux', - ]; - - const promises = apps.map( - async (app) => - getDataHandler(app)?.hasData(app === 'ux' ? { absoluteTime } : undefined) || false - ); - - const results = await Promise.allSettled(promises); - - const [apm, uptime, logs, metrics, ux] = results.map((result) => { - if (result.status === 'fulfilled') { - return result.value; - } - - console.error('Error while fetching has data', result.reason); - return false; - }); - - return { - apm, - uptime, - ux, - infra_logs: logs, - infra_metrics: metrics, - }; -} diff --git a/x-pack/plugins/observability/public/hooks/use_has_data.ts b/x-pack/plugins/observability/public/hooks/use_has_data.ts new file mode 100644 index 000000000000..9c66fa886142 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_has_data.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; +import { HasDataContext } from '../context/has_data_context'; + +export function useHasData() { + return useContext(HasDataContext); +} diff --git a/x-pack/plugins/observability/public/hooks/use_route_params.tsx b/x-pack/plugins/observability/public/hooks/use_route_params.tsx index 1b32933eec3e..9774d9bed424 100644 --- a/x-pack/plugins/observability/public/hooks/use_route_params.tsx +++ b/x-pack/plugins/observability/public/hooks/use_route_params.tsx @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { useLocation, useParams } from 'react-router-dom'; import { isLeft } from 'fp-ts/lib/Either'; import { PathReporter } from 'io-ts/lib/PathReporter'; -import { Params } from '../routes'; +import { Params, RouteParams, routes } from '../routes'; function getQueryParams(location: ReturnType) { const urlSearchParms = new URLSearchParams(location.search); @@ -23,14 +23,15 @@ function getQueryParams(location: ReturnType) { * It removes any aditional item which is not declared in the type. * @param params */ -export function useRouteParams(params: Params) { +export function useRouteParams(pathName: T): RouteParams { const location = useLocation(); const pathParams = useParams(); const queryParams = getQueryParams(location); + const { query, path } = routes[pathName].params as Params; const rts = { - queryRt: params.query ? t.exact(params.query) : t.strict({}), - pathRt: params.path ? t.exact(params.path) : t.strict({}), + queryRt: query ? t.exact(query) : t.strict({}), + pathRt: path ? t.exact(path) : t.strict({}), }; const queryResult = rts.queryRt.decode(queryParams); @@ -43,8 +44,8 @@ export function useRouteParams(params: Params) { console.error(PathReporter.report(pathResult)[0]); } - return { + return ({ query: isLeft(queryResult) ? {} : queryResult.right, path: isLeft(pathResult) ? {} : pathResult.right, - }; + } as unknown) as RouteParams; } diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.test.ts b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts new file mode 100644 index 000000000000..c89d52f904a9 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_time_range.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useTimeRange } from './use_time_range'; +import * as pluginContext from './use_plugin_context'; +import { AppMountParameters, CoreStart } from 'kibana/public'; +import { ObservabilityPluginSetupDeps } from '../plugin'; +import * as kibanaUISettings from './use_kibana_ui_settings'; + +jest.mock('react-router-dom', () => ({ + useLocation: () => ({ + pathname: '/observability/overview/', + search: '', + }), +})); + +describe('useTimeRange', () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: {} as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: '2020-10-08T06:00:00.000Z', + to: '2020-10-08T07:00:00.000Z', + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + jest.spyOn(kibanaUISettings, 'useKibanaUISettings').mockImplementation(() => ({ + from: '2020-10-08T05:00:00.000Z', + to: '2020-10-08T06:00:00.000Z', + })); + }); + + describe('when range from and to are not provided', () => { + describe('when data plugin has time set', () => { + it('returns ranges and absolute times from data plugin', () => { + const relativeStart = '2020-10-08T06:00:00.000Z'; + const relativeEnd = '2020-10-08T07:00:00.000Z'; + const timeRange = useTimeRange(); + expect(timeRange).toEqual({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + }); + }); + }); + describe("when data plugin doesn't have time set", () => { + beforeAll(() => { + jest.spyOn(pluginContext, 'usePluginContext').mockImplementation(() => ({ + core: {} as CoreStart, + appMountParameters: {} as AppMountParameters, + plugins: ({ + data: { + query: { + timefilter: { + timefilter: { + getTime: jest.fn().mockImplementation(() => ({ + from: undefined, + to: undefined, + })), + }, + }, + }, + }, + } as unknown) as ObservabilityPluginSetupDeps, + })); + }); + it('returns ranges and absolute times from kibana default settings', () => { + const relativeStart = '2020-10-08T05:00:00.000Z'; + const relativeEnd = '2020-10-08T06:00:00.000Z'; + const timeRange = useTimeRange(); + expect(timeRange).toEqual({ + relativeStart, + relativeEnd, + absoluteStart: new Date(relativeStart).valueOf(), + absoluteEnd: new Date(relativeEnd).valueOf(), + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/hooks/use_time_range.ts b/x-pack/plugins/observability/public/hooks/use_time_range.ts new file mode 100644 index 000000000000..e8bed12aaa9b --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_time_range.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'query-string'; +import { useLocation } from 'react-router-dom'; +import { TimePickerTime } from '../components/shared/date_picker'; +import { getAbsoluteTime } from '../utils/date'; +import { UI_SETTINGS, useKibanaUISettings } from './use_kibana_ui_settings'; +import { usePluginContext } from './use_plugin_context'; + +const getParsedParams = (search: string) => { + return parse(search.slice(1), { sort: false }); +}; + +export function useTimeRange() { + const { plugins } = usePluginContext(); + + const timePickerTimeDefaults = useKibanaUISettings( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); + + const { rangeFrom, rangeTo } = getParsedParams(useLocation().search); + + const relativeStart = (rangeFrom ?? + timePickerSharedState.from ?? + timePickerTimeDefaults.from) as string; + const relativeEnd = (rangeTo ?? timePickerSharedState.to ?? timePickerTimeDefaults.to) as string; + + return { + relativeStart, + relativeEnd, + absoluteStart: getAbsoluteTime(relativeStart)!, + absoluteEnd: getAbsoluteTime(relativeEnd, { roundUp: true })!, + }; +} diff --git a/x-pack/plugins/observability/public/pages/home/index.test.tsx b/x-pack/plugins/observability/public/pages/home/index.test.tsx new file mode 100644 index 000000000000..2c06b7035f51 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/home/index.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { HasDataContextValue } from '../../context/has_data_context'; +import * as hasData from '../../hooks/use_has_data'; +import { render } from '../../utils/test_helper'; +import { HomePage } from './'; + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +describe('Home page', () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + it('renders loading component while requests are not returned', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: false, isAllRequestsComplete: false } as HasDataContextValue) + ); + const { getByText } = render(); + expect(getByText('Loading Observability')).toBeInTheDocument(); + }); + it('renders landing page', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: false, isAllRequestsComplete: true } as HasDataContextValue) + ); + render(); + expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/landing' }); + }); + it('renders overview page', () => { + jest + .spyOn(hasData, 'useHasData') + .mockImplementation( + () => + ({ hasData: {}, hasAnyData: true, isAllRequestsComplete: false } as HasDataContextValue) + ); + render(); + expect(mockHistoryPush).toHaveBeenCalledWith({ pathname: '/overview' }); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/home/index.tsx b/x-pack/plugins/observability/public/pages/home/index.tsx index 77b812dddd32..a2a7cad1d562 100644 --- a/x-pack/plugins/observability/public/pages/home/index.tsx +++ b/x-pack/plugins/observability/public/pages/home/index.tsx @@ -5,33 +5,20 @@ */ import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { fetchHasData } from '../../data_handler'; -import { useFetcher } from '../../hooks/use_fetcher'; -import { useQueryParams } from '../../hooks/use_query_params'; +import { useHasData } from '../../hooks/use_has_data'; import { LoadingObservability } from '../overview/loading_observability'; export function HomePage() { const history = useHistory(); - - const { absStart, absEnd } = useQueryParams(); - - const { data = {} } = useFetcher( - () => fetchHasData({ start: absStart, end: absEnd }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - - const values = Object.values(data); - const hasSomeData = values.length ? values.some((hasData) => hasData) : null; + const { hasAnyData, isAllRequestsComplete } = useHasData(); useEffect(() => { - if (hasSomeData === true) { + if (hasAnyData === true) { history.push({ pathname: '/overview' }); - } - if (hasSomeData === false) { + } else if (hasAnyData === false && isAllRequestsComplete === true) { history.push({ pathname: '/landing' }); } - }, [hasSomeData, history]); + }, [hasAnyData, isAllRequestsComplete, history]); return ; } diff --git a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx index 2d3142d4e580..f0c56eb7137e 100644 --- a/x-pack/plugins/observability/public/pages/overview/data_sections.tsx +++ b/x-pack/plugins/observability/public/pages/overview/data_sections.tsx @@ -4,76 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import { APMSection } from '../../components/app/section/apm'; import { LogsSection } from '../../components/app/section/logs'; import { MetricsSection } from '../../components/app/section/metrics'; -import { APMSection } from '../../components/app/section/apm'; import { UptimeSection } from '../../components/app/section/uptime'; import { UXSection } from '../../components/app/section/ux'; -import { - HasDataResponse, - ObservabilityFetchDataPlugins, - UXHasDataResponse, -} from '../../typings/fetch_overview_data'; +import { HasDataMap } from '../../context/has_data_context'; interface Props { bucketSize: string; - absoluteTime: { start?: number; end?: number }; - relativeTime: { start: string; end: string }; - hasData: Record; + hasData?: Partial; } -export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) { +export function DataSections({ bucketSize }: Props) { return ( - {hasData?.infra_logs && ( - - - - )} - {hasData?.infra_metrics && ( - - - - )} - {hasData?.apm && ( - - - - )} - {hasData?.uptime && ( - - - - )} - {(hasData.ux as UXHasDataResponse).hasData && ( - - - - )} + + + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index d85bd1a624d7..87a836b2cb32 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -3,27 +3,25 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; -import { useTrackPageview, UXHasDataResponse } from '../..'; -import { EmptySection } from '../../components/app/empty_section'; +import { useTrackPageview } from '../..'; +import { Alert } from '../../../../alerts/common'; +import { EmptySections } from '../../components/app/empty_sections'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; import { NewsFeed } from '../../components/app/news_feed'; import { Resources } from '../../components/app/resources'; import { AlertsSection } from '../../components/app/section/alerts'; -import { DatePicker, TimePickerTime } from '../../components/shared/date_picker'; -import { fetchHasData } from '../../data_handler'; -import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; -import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings'; +import { DatePicker } from '../../components/shared/date_picker'; +import { useFetcher } from '../../hooks/use_fetcher'; +import { useHasData } from '../../hooks/use_has_data'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useTimeRange } from '../../hooks/use_time_range'; import { RouteParams } from '../../routes'; import { getNewsFeed } from '../../services/get_news_feed'; -import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { DataSections } from './data_sections'; -import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; interface Props { @@ -37,47 +35,26 @@ function calculateBucketSize({ start, end }: { start?: number; end?: number }) { } export function OverviewPage({ routeParams }: Props) { - const { core, plugins } = usePluginContext(); - - // read time from state and update the url - const timePickerSharedState = plugins.data.query.timefilter.timefilter.getTime(); - - const timePickerDefaults = useKibanaUISettings( - UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS - ); - - const relativeTime = { - start: routeParams.query.rangeFrom || timePickerSharedState.from || timePickerDefaults.from, - end: routeParams.query.rangeTo || timePickerSharedState.to || timePickerDefaults.to, - }; - - const absoluteTime = { - start: getAbsoluteTime(relativeTime.start) as number, - end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number, - }; - useTrackPageview({ app: 'observability-overview', path: 'overview' }); useTrackPageview({ app: 'observability-overview', path: 'overview', delay: 15000 }); + const { core } = usePluginContext(); + const theme = useContext(ThemeContext); - const { data: alerts = [], status: alertStatus } = useFetcher(() => { - return getObservabilityAlerts({ core }); - }, [core]); + const { relativeStart, relativeEnd, absoluteStart, absoluteEnd } = useTimeRange(); + + const relativeTime = { start: relativeStart, end: relativeEnd }; + const absoluteTime = { start: absoluteStart, end: absoluteEnd }; const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]); - const theme = useContext(ThemeContext); + const { hasData, hasAnyData } = useHasData(); - const result = useFetcher( - () => fetchHasData(absoluteTime), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - const hasData = result.data; - - if (!hasData) { + if (hasAnyData === undefined) { return ; } + const alerts = (hasData.alert?.hasData as Alert[]) || []; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; const bucketSize = calculateBucketSize({ @@ -85,18 +62,6 @@ export function OverviewPage({ routeParams }: Props) { end: absoluteTime.end, }); - const appEmptySections = getEmptySections({ core }).filter(({ id }) => { - if (id === 'alert') { - return alertStatus !== FETCH_STATUS.FAILURE && !alerts.length; - } else if (id === 'ux') { - return !(hasData[id] as UXHasDataResponse).hasData; - } - return !hasData[id]; - }); - - // Hides the data section when all 'hasData' is false or undefined - const showDataSections = Object.values(hasData).some((hasPluginData) => hasPluginData); - return ( {/* Data sections */} - {showDataSections && ( - - )} + {hasAnyData && } - {/* Empty sections */} - {!!appEmptySections.length && ( - - - 2 ? 2 : 1 - } - gutterSize="s" - > - {appEmptySections.map((app) => { - return ( - - - - ); - })} - - - )} + {/* Alert section */} diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 8713bb122927..a28e34e7d4dc 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -10,6 +10,7 @@ import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { HasDataContextProvider } from '../../context/has_data_context'; import { PluginContext } from '../../context/plugin_context'; import { registerDataHandler, unregisterDataHandler } from '../../data_handler'; import { ObservabilityPluginSetupDeps } from '../../plugin'; @@ -52,7 +53,9 @@ const withCore = makeDecorator({ } as unknown) as ObservabilityPluginSetupDeps, }} > - {storyFn(context)} + + {storyFn(context)} + ); diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts index 64f5f4aab1c2..e3f8f877656b 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { getObservabilityAlerts } from './get_observability_alerts'; const basePath = { prepend: (path: string) => path }; @@ -27,10 +27,9 @@ describe('getObservabilityAlerts', () => { }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; - const alerts = await getObservabilityAlerts({ core }); - expect(alerts).toEqual([]); + expect(getObservabilityAlerts({ core })).rejects.toThrow('Boom'); }); it('Returns empty array when api return undefined', async () => { @@ -43,7 +42,7 @@ describe('getObservabilityAlerts', () => { }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([]); @@ -55,32 +54,17 @@ describe('getObservabilityAlerts', () => { get: async () => { return { data: [ - { - id: 1, - consumer: 'siem', - }, - { - id: 2, - consumer: 'kibana', - }, - { - id: 3, - consumer: 'index', - }, - { - id: 4, - consumer: 'foo', - }, - { - id: 5, - consumer: 'bar', - }, + { id: 1, consumer: 'siem' }, + { id: 2, consumer: 'kibana' }, + { id: 3, consumer: 'index' }, + { id: 4, consumer: 'foo' }, + { id: 5, consumer: 'bar' }, ], }; }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([]); }); @@ -91,36 +75,18 @@ describe('getObservabilityAlerts', () => { get: async () => { return { data: [ - { - id: 1, - consumer: 'siem', - }, - { - id: 2, - consumer: 'apm', - }, - { - id: 3, - consumer: 'uptime', - }, - { - id: 4, - consumer: 'logs', - }, - { - id: 5, - consumer: 'metrics', - }, - { - id: 6, - consumer: 'alerts', - }, + { id: 1, consumer: 'siem' }, + { id: 2, consumer: 'apm' }, + { id: 3, consumer: 'uptime' }, + { id: 4, consumer: 'logs' }, + { id: 5, consumer: 'metrics' }, + { id: 6, consumer: 'alerts' }, ], }; }, basePath, }, - } as unknown) as AppMountContext['core']; + } as unknown) as CoreStart; const alerts = await getObservabilityAlerts({ core }); expect(alerts).toEqual([ diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index cff6726e47df..b1f8f0fb1bdd 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -4,23 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMountContext } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { Alert } from '../../../alerts/common'; const allowedConsumers = ['apm', 'uptime', 'logs', 'metrics', 'alerts']; -export async function getObservabilityAlerts({ core }: { core: AppMountContext['core'] }) { +export async function getObservabilityAlerts({ core }: { core: CoreStart }) { try { - const { data = [] }: { data: Alert[] } = await core.http.get('/api/alerts/_find', { - query: { - page: 1, - per_page: 20, - }, - }); + const { data = [] }: { data: Alert[] } = + (await core.http.get('/api/alerts/_find', { + query: { + page: 1, + per_page: 20, + }, + })) || {}; return data.filter(({ consumer }) => allowedConsumers.includes(consumer)); } catch (e) { console.error('Error while fetching alerts', e); - return []; + throw e; } } diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 70c1eb1859ee..4cac1d586f29 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -37,13 +37,13 @@ export interface UXHasDataResponse { serviceName: string | number | undefined; } -export type HasDataResponse = UXHasDataResponse | boolean; - export type FetchData = ( fetchDataParams: FetchDataParams ) => Promise; -export type HasData = (params?: HasDataParams) => Promise; +export type HasData = ( + params?: HasDataParams +) => Promise; export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, @@ -54,7 +54,7 @@ export interface DataHandler< T extends ObservabilityFetchDataPlugins = ObservabilityFetchDataPlugins > { fetchData: FetchData; - hasData: HasData; + hasData: HasData; } export interface FetchDataResponse { @@ -113,3 +113,11 @@ export interface ObservabilityFetchDataResponse { uptime: UptimeFetchDataResponse; ux: UxFetchDataResponse; } + +export interface ObservabilityHasDataResponse { + apm: boolean; + infra_metrics: boolean; + infra_logs: boolean; + uptime: boolean; + ux: UXHasDataResponse; +}