[UX] Add core web vitals in obsv homepage (#78976)

This commit is contained in:
Shahzad 2020-10-05 19:15:13 +02:00 committed by GitHub
parent 28278abdda
commit de130abfbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1032 additions and 390 deletions

View file

@ -1,58 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations';
import { CoreVitalItem } from './CoreVitalItem';
import { UXMetrics } from '../UXMetrics';
import { formatToSec } from '../UXMetrics/KeyUXMetrics';
const CoreVitalsThresholds = {
LCP: { good: '2.5s', bad: '4.0s' },
FID: { good: '100ms', bad: '300ms' },
CLS: { good: '0.1', bad: '0.25' },
};
interface Props {
data?: UXMetrics | null;
loading: boolean;
}
export function CoreVitals({ data, loading }: Props) {
const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {};
return (
<EuiFlexGroup gutterSize="xl" justifyContent={'spaceBetween'} wrap>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={LCP_LABEL}
value={formatToSec(lcp, 'ms')}
ranks={lcpRanks}
loading={loading}
thresholds={CoreVitalsThresholds.LCP}
/>
</EuiFlexItem>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={FID_LABEL}
value={formatToSec(fid, 'ms')}
ranks={fidRanks}
loading={loading}
thresholds={CoreVitalsThresholds.FID}
/>
</EuiFlexItem>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={CLS_LABEL}
value={cls ?? '0'}
ranks={clsRanks}
loading={loading}
thresholds={CoreVitalsThresholds.CLS}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,92 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const LCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.lcp', {
defaultMessage: 'Largest contentful paint',
});
export const FID_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fip', {
defaultMessage: 'First input delay',
});
export const CLS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.cls', {
defaultMessage: 'Cumulative layout shift',
});
export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', {
defaultMessage: 'First contentful paint',
});
export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', {
defaultMessage: 'Total blocking time',
});
export const NO_OF_LONG_TASK = i18n.translate(
'xpack.apm.rum.uxMetrics.noOfLongTasks',
{
defaultMessage: 'No. of long tasks',
}
);
export const LONGEST_LONG_TASK = i18n.translate(
'xpack.apm.rum.uxMetrics.longestLongTasks',
{
defaultMessage: 'Longest long task duration',
}
);
export const SUM_LONG_TASKS = i18n.translate(
'xpack.apm.rum.uxMetrics.sumLongTasks',
{
defaultMessage: 'Total long tasks duration',
}
);
export const CV_POOR_LABEL = i18n.translate('xpack.apm.rum.coreVitals.poor', {
defaultMessage: 'a poor',
});
export const CV_GOOD_LABEL = i18n.translate('xpack.apm.rum.coreVitals.good', {
defaultMessage: 'a good',
});
export const CV_AVERAGE_LABEL = i18n.translate(
'xpack.apm.rum.coreVitals.average',
{
defaultMessage: 'an average',
}
);
export const LEGEND_POOR_LABEL = i18n.translate(
'xpack.apm.rum.coreVitals.legends.poor',
{
defaultMessage: 'Poor',
}
);
export const LEGEND_GOOD_LABEL = i18n.translate(
'xpack.apm.rum.coreVitals.legends.good',
{
defaultMessage: 'Good',
}
);
export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate(
'xpack.apm.rum.coreVitals.legends.needsImprovement',
{
defaultMessage: 'Needs improvement',
}
);
export const MORE_LABEL = i18n.translate('xpack.apm.rum.coreVitals.more', {
defaultMessage: 'more',
});
export const LESS_LABEL = i18n.translate('xpack.apm.rum.coreVitals.less', {
defaultMessage: 'less',
});

View file

@ -7,16 +7,16 @@
import React from 'react';
import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { UXMetrics } from './index';
import {
FCP_LABEL,
LONGEST_LONG_TASK,
NO_OF_LONG_TASK,
SUM_LONG_TASKS,
TBT_LABEL,
} from '../CoreVitals/translations';
} from './translations';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUxQuery } from '../hooks/useUxQuery';
import { UXMetrics } from '../../../../../../observability/public';
export function formatToSec(
value?: number | string,

View file

@ -4,36 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLink,
EuiPanel,
EuiPopover,
EuiSpacer,
EuiTitle,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { I18LABELS } from '../translations';
import { CoreVitals } from '../CoreVitals';
import { KeyUXMetrics } from './KeyUXMetrics';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUxQuery } from '../hooks/useUxQuery';
export interface UXMetrics {
cls: string;
fid: number;
lcp: number;
tbt: number;
fcp: number;
lcpRanks: number[];
fidRanks: number[];
clsRanks: number[];
}
import { CoreVitals } from '../../../../../../observability/public';
export function UXMetrics() {
const uxQuery = useUxQuery();
@ -53,10 +37,6 @@ export function UXMetrics() {
[uxQuery]
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = () => setIsPopoverOpen(false);
return (
<EuiPanel>
<EuiFlexGroup justifyContent="spaceBetween" wrap>
@ -72,39 +52,6 @@ export function UXMetrics() {
<EuiFlexGroup justifyContent="spaceBetween" wrap>
<EuiFlexItem grow={1} data-cy={`client-metrics`}>
<EuiTitle size="xs">
<h3>
{I18LABELS.coreWebVitals}
<EuiPopover
isOpen={isPopoverOpen}
button={
<EuiButtonIcon
onClick={() => setIsPopoverOpen(true)}
color={'text'}
iconType={'questionInCircle'}
/>
}
closePopover={closePopover}
>
<div style={{ width: '300px' }}>
<EuiText>
<FormattedMessage
id="xpack.apm.ux.dashboard.webCoreVitals.help"
defaultMessage="Learn more about"
/>
<EuiLink
href="https://web.dev/vitals/"
external
target="_blank"
>
{' '}
{I18LABELS.coreWebVitals}
</EuiLink>
</EuiText>
</div>
</EuiPopover>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<CoreVitals data={data} loading={status !== 'success'} />
</EuiFlexItem>

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', {
defaultMessage: 'First contentful paint',
});
export const TBT_LABEL = i18n.translate('xpack.apm.rum.coreVitals.tbt', {
defaultMessage: 'Total blocking time',
});
export const NO_OF_LONG_TASK = i18n.translate(
'xpack.apm.rum.uxMetrics.noOfLongTasks',
{
defaultMessage: 'No. of long tasks',
}
);
export const LONGEST_LONG_TASK = i18n.translate(
'xpack.apm.rum.uxMetrics.longestLongTasks',
{
defaultMessage: 'Longest long task duration',
}
);
export const SUM_LONG_TASKS = i18n.translate(
'xpack.apm.rum.uxMetrics.sumLongTasks',
{
defaultMessage: 'Total long tasks duration',
}
);

View file

@ -0,0 +1,49 @@
/*
* 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 {
FetchDataParams,
HasDataParams,
UxFetchDataResponse,
} from '../../../../../observability/public/';
import { callApmApi } from '../../../services/rest/createCallApmApi';
export { createCallApmApi } from '../../../services/rest/createCallApmApi';
export const fetchUxOverviewDate = async ({
absoluteTime,
relativeTime,
serviceName,
}: FetchDataParams): Promise<UxFetchDataResponse> => {
const data = await callApmApi({
pathname: '/api/apm/rum-client/web-core-vitals',
params: {
query: {
start: new Date(absoluteTime.start).toISOString(),
end: new Date(absoluteTime.end).toISOString(),
uiFilters: `{"serviceName":["${serviceName}"]}`,
},
},
});
return {
coreWebVitals: data,
appLink: `/app/ux?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`,
};
};
export async function hasRumData({ absoluteTime }: HasDataParams) {
return await callApmApi({
pathname: '/api/apm/observability_overview/has_rum_data',
params: {
query: {
start: new Date(absoluteTime.start).toISOString(),
end: new Date(absoluteTime.end).toISOString(),
uiFilters: '',
},
},
});
}

View file

@ -238,8 +238,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"baseHash": 211589981,
"componentId": "sc-fznyAO",
"isStatic": false,
"rules": Array [
"
@ -254,7 +254,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-fzoLsD",
"styledComponentId": "sc-fznyAO",
"target": "span",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -444,8 +444,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -1474970742,
"componentId": "sc-Axmtr",
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"isStatic": false,
"rules": Array [
"
@ -462,7 +462,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-Axmtr",
"styledComponentId": "sc-fzoLsD",
"target": "code",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -474,8 +474,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": 1882630949,
"componentId": "sc-AxheI",
"baseHash": 1280172402,
"componentId": "sc-fzozJi",
"isStatic": false,
"rules": Array [
"
@ -500,7 +500,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-AxheI",
"styledComponentId": "sc-fzozJi",
"target": "pre",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -669,8 +669,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -1474970742,
"componentId": "sc-Axmtr",
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"isStatic": false,
"rules": Array [
"
@ -687,7 +687,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-Axmtr",
"styledComponentId": "sc-fzoLsD",
"target": "code",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -699,8 +699,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": 1882630949,
"componentId": "sc-AxheI",
"baseHash": 1280172402,
"componentId": "sc-fzozJi",
"isStatic": false,
"rules": Array [
"
@ -725,7 +725,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-AxheI",
"styledComponentId": "sc-fzozJi",
"target": "pre",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -895,8 +895,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -1474970742,
"componentId": "sc-Axmtr",
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"isStatic": false,
"rules": Array [
"
@ -913,7 +913,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-Axmtr",
"styledComponentId": "sc-fzoLsD",
"target": "code",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -925,8 +925,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": 1882630949,
"componentId": "sc-AxheI",
"baseHash": 1280172402,
"componentId": "sc-fzozJi",
"isStatic": false,
"rules": Array [
"
@ -951,7 +951,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-AxheI",
"styledComponentId": "sc-fzozJi",
"target": "pre",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -1131,8 +1131,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -1474970742,
"componentId": "sc-Axmtr",
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"isStatic": false,
"rules": Array [
"
@ -1149,7 +1149,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-Axmtr",
"styledComponentId": "sc-fzoLsD",
"target": "code",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -1161,8 +1161,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": 1882630949,
"componentId": "sc-AxheI",
"baseHash": 1280172402,
"componentId": "sc-fzozJi",
"isStatic": false,
"rules": Array [
"
@ -1187,7 +1187,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-AxheI",
"styledComponentId": "sc-fzozJi",
"target": "pre",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -1384,8 +1384,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": -1474970742,
"componentId": "sc-Axmtr",
"baseHash": -2021127760,
"componentId": "sc-fzoLsD",
"isStatic": false,
"rules": Array [
"
@ -1402,7 +1402,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-Axmtr",
"styledComponentId": "sc-fzoLsD",
"target": "code",
"toString": [Function],
"warnTooManyClasses": [Function],
@ -1414,8 +1414,8 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"baseHash": 1882630949,
"componentId": "sc-AxheI",
"baseHash": 1280172402,
"componentId": "sc-fzozJi",
"isStatic": false,
"rules": Array [
"
@ -1440,7 +1440,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`]
"foldedComponentIds": Array [],
"render": [Function],
"shouldForwardProp": undefined,
"styledComponentId": "sc-AxheI",
"styledComponentId": "sc-fzozJi",
"target": "pre",
"toString": [Function],
"warnTooManyClasses": [Function],

View file

@ -7,6 +7,7 @@
import { ConfigSchema } from '.';
import {
FetchDataParams,
HasDataParams,
ObservabilityPluginSetup,
} from '../../observability/public';
import {
@ -100,6 +101,30 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
return await dataHelper.fetchOverviewPageData(params);
},
});
const getUxDataHelper = async () => {
const {
fetchUxOverviewDate,
hasRumData,
createCallApmApi,
} = await import('./components/app/RumDashboard/ux_overview_fetchers');
// have to do this here as well in case app isn't mounted yet
createCallApmApi(core.http);
return { fetchUxOverviewDate, hasRumData };
};
plugins.observability.dashboard.register({
appName: 'ux',
hasData: async (params?: HasDataParams) => {
const dataHelper = await getUxDataHelper();
return await dataHelper.hasRumData(params!);
},
fetchData: async (params: FetchDataParams) => {
const dataHelper = await getUxDataHelper();
return await dataHelper.fetchUxOverviewDate(params);
},
});
}
core.application.register({

View file

@ -0,0 +1,59 @@
/*
* 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 { Setup, SetupTimeRange } from '../helpers/setup_request';
import {
SERVICE_NAME,
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { rangeFilter } from '../../../common/utils/range_filter';
import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types';
export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) {
try {
const { start, end } = setup;
const params = {
apm: {
events: [ProcessorEvent.transaction],
},
body: {
size: 0,
query: {
bool: {
filter: [{ term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }],
},
},
aggs: {
services: {
filter: {
range: rangeFilter(start, end),
},
aggs: {
mostTraffic: {
terms: {
field: SERVICE_NAME,
size: 1,
},
},
},
},
},
},
};
const { apmEventClient } = setup;
const response = await apmEventClient.search(params);
return {
hasData: response.hits.total.value > 0,
serviceName:
response.aggregations?.services?.mostTraffic?.buckets?.[0]?.key,
};
} catch (e) {
return false;
}
}

View file

@ -79,6 +79,7 @@ import {
anomalyDetectionEnvironmentsRoute,
} from './settings/anomaly_detection';
import {
rumHasDataRoute,
rumClientMetricsRoute,
rumJSErrors,
rumLongTaskMetrics,
@ -186,7 +187,8 @@ const createApmApi = () => {
.add(rumWebCoreVitals)
.add(rumJSErrors)
.add(rumUrlSearch)
.add(rumLongTaskMetrics);
.add(rumLongTaskMetrics)
.add(rumHasDataRoute);
return api;
};

View file

@ -18,6 +18,7 @@ import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals';
import { getJSErrors } from '../lib/rum_client/get_js_errors';
import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics';
import { getUrlSearch } from '../lib/rum_client/get_url_search';
import { hasRumData } from '../lib/rum_client/has_rum_data';
export const percentileRangeRt = t.partial({
minPercentile: t.string,
@ -227,3 +228,14 @@ export const rumJSErrors = createRoute(() => ({
});
},
}));
export const rumHasDataRoute = createRoute(() => ({
path: '/api/apm/observability_overview/has_rum_data',
params: {
query: t.intersection([uiFiltersRt, rangeRt]),
},
handler: async ({ context, request }) => {
const setup = await setupRequest(context, request);
return await hasRumData({ setup });
},
}));

View file

@ -20,6 +20,7 @@ describe('renderApp', () => {
chrome: { docTitle: { change: () => {} }, setBreadcrumbs: () => {} },
i18n: { Context: ({ children }: { children: React.ReactNode }) => children },
uiSettings: { get: () => false },
http: { basePath: { prepend: (path: string) => path } },
} as unknown) as CoreStart;
const params = ({
element: window.document.createElement('div'),

View file

@ -123,7 +123,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props)
defaultMessage: 'Down',
})}
series={series?.down}
ticktFormatter={formatter}
tickFormatter={formatter}
color={downColor}
/>
<UptimeBarSeries
@ -132,7 +132,7 @@ export function UptimeSection({ absoluteTime, relativeTime, bucketSize }: Props)
defaultMessage: 'Up',
})}
series={series?.up}
ticktFormatter={formatter}
tickFormatter={formatter}
color={upColor}
/>
</ChartContainer>
@ -145,13 +145,13 @@ function UptimeBarSeries({
label,
series,
color,
ticktFormatter,
tickFormatter,
}: {
id: string;
label: string;
series?: Series;
color: string;
ticktFormatter: TickFormatter;
tickFormatter: TickFormatter;
}) {
if (!series) {
return null;
@ -178,7 +178,7 @@ function UptimeBarSeries({
position={Position.Bottom}
showOverlappingTicks={false}
showOverlappingLabels={false}
tickFormat={ticktFormatter}
tickFormat={tickFormatter}
/>
<Axis
id="y-axis"

View file

@ -0,0 +1,84 @@
/*
* 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 moment from 'moment';
import * as fetcherHook from '../../../../hooks/use_fetcher';
import { render } from '../../../../utils/test_helper';
import { UXSection } from './';
import { response } from './mock_data/ux.mock';
describe('UXSection', () => {
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(
<UXSection
absoluteTime={{
start: moment('2020-06-29T11:38:23.747Z').valueOf(),
end: moment('2020-06-29T12:08:23.748Z').valueOf(),
}}
relativeTime={{ start: 'now-15m', end: 'now' }}
bucketSize="60s"
serviceName="elastic-co-frontend"
/>
);
expect(getByText('User Experience')).toBeInTheDocument();
expect(getByText('View in app')).toBeInTheDocument();
expect(getByText('elastic-co-frontend')).toBeInTheDocument();
expect(getByText('Largest contentful paint')).toBeInTheDocument();
expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument();
expect(getByText('First input delay 14 ms')).toBeInTheDocument();
expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument();
expect(getByText('Largest contentful paint')).toBeInTheDocument();
expect(getByText('Largest contentful paint 1.94 s')).toBeInTheDocument();
expect(getByText('First input delay 14 ms')).toBeInTheDocument();
expect(getByText('Cumulative layout shift 0.01')).toBeInTheDocument();
// LCP Rank Values
expect(getByText('Good (65%)')).toBeInTheDocument();
expect(getByText('Needs improvement (19%)')).toBeInTheDocument();
// LCP and FID both have same poor value
expect(getAllByText('Poor (16%)')).toHaveLength(2);
// FID Rank Values
expect(getByText('Good (73%)')).toBeInTheDocument();
expect(getByText('Needs improvement (11%)')).toBeInTheDocument();
// CLS Rank Values
expect(getByText('Good (86%)')).toBeInTheDocument();
expect(getByText('Needs improvement (8%)')).toBeInTheDocument();
expect(getByText('Poor (6%)')).toBeInTheDocument();
});
it('shows loading state', () => {
jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
data: undefined,
status: fetcherHook.FETCH_STATUS.LOADING,
refetch: jest.fn(),
});
const { getByText, queryAllByText, getAllByText } = render(
<UXSection
absoluteTime={{
start: moment('2020-06-29T11:38:23.747Z').valueOf(),
end: moment('2020-06-29T12:08:23.748Z').valueOf(),
}}
relativeTime={{ start: 'now-15m', end: 'now' }}
bucketSize="60s"
serviceName="elastic-co-frontend"
/>
);
expect(getByText('User Experience')).toBeInTheDocument();
expect(getAllByText('Statistic is loading')).toHaveLength(3);
expect(queryAllByText('View in app')).toEqual([]);
expect(getByText('elastic-co-frontend')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React from 'react';
import { SectionContainer } from '../';
import { getDataHandler } from '../../../../data_handler';
import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher';
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;
const { data, status } = useFetcher(() => {
if (start && end) {
return getDataHandler('ux')?.fetchData({
absoluteTime: { start, end },
relativeTime,
serviceName,
bucketSize,
});
}
}, [start, end, relativeTime, serviceName, bucketSize]);
const isLoading = status === FETCH_STATUS.LOADING;
const { appLink, coreWebVitals } = data || {};
return (
<SectionContainer
title={i18n.translate('xpack.observability.overview.ux.title', {
defaultMessage: 'User Experience',
})}
appLink={{
href: appLink,
label: i18n.translate('xpack.observability.overview.ux.appLink', {
defaultMessage: 'View in app',
}),
}}
hasError={status === FETCH_STATUS.FAILURE}
>
<CoreVitals
data={coreWebVitals}
loading={isLoading}
displayServiceName={true}
serviceName={serviceName}
/>
</SectionContainer>
);
}

View file

@ -0,0 +1,21 @@
/*
* 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 { UxFetchDataResponse } from '../../../../../typings';
export const response: UxFetchDataResponse = {
appLink: '/app/ux',
coreWebVitals: {
cls: '0.01',
fid: 13.5,
lcp: 1942.6666666666667,
tbt: 281.55833333333334,
fcp: 1487,
lcpRanks: [65, 19, 16],
fidRanks: [73, 11, 16],
clsRanks: [86, 8, 6],
},
};

View file

@ -8,10 +8,10 @@ import React, { ComponentType } from 'react';
import { IntlProvider } from 'react-intl';
import { Observable } from 'rxjs';
import { CoreStart } from 'src/core/public';
import { createKibanaReactContext } from '../../../../../../../../../src/plugins/kibana_react/public';
import { EuiThemeProvider } from '../../../../../../../observability/public';
import { CoreVitalItem } from '../CoreVitalItem';
import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public';
import { CoreVitalItem } from '../core_vital_item';
import { LCP_LABEL } from '../translations';
import { EuiThemeProvider } from '../../../../typings';
const KibanaReactContext = createKibanaReactContext(({
uiSettings: { get: () => {}, get$: () => new Observable() },

View file

@ -14,12 +14,7 @@ const ColoredSpan = styled.div`
cursor: pointer;
`;
const getSpanStyle = (
position: number,
inFocus: boolean,
hexCode: string,
percentage: number
) => {
const getSpanStyle = (position: number, inFocus: boolean, hexCode: string, percentage: number) => {
let first = position === 0 || percentage === 100;
let last = position === 2 || percentage === 100;
if (percentage === 100) {

View file

@ -4,16 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiFlexGroup,
euiPaletteForStatus,
EuiSpacer,
EuiStat,
} from '@elastic/eui';
import { EuiFlexGroup, euiPaletteForStatus, EuiSpacer, EuiStat } from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { PaletteLegends } from './PaletteLegends';
import { ColorPaletteFlexItem } from './ColorPaletteFlexItem';
import { PaletteLegends } from './palette_legends';
import { ColorPaletteFlexItem } from './color_palette_flex_item';
import {
CV_AVERAGE_LABEL,
CV_GOOD_LABEL,
@ -45,7 +40,7 @@ export function getCoreVitalTooltipMessage(
const bad = position === 2;
const average = !good && !bad;
return i18n.translate('xpack.apm.csm.dashboard.webVitals.palette.tooltip', {
return i18n.translate('xpack.observability.ux.dashboard.webVitals.palette.tooltip', {
defaultMessage:
'{percentage} % of users have {exp} experience because the {title} takes {moreOrLess} than {value}{averageMessage}.',
values: {
@ -55,7 +50,7 @@ export function getCoreVitalTooltipMessage(
moreOrLess: bad || average ? MORE_LABEL : LESS_LABEL,
value: good || average ? thresholds.good : thresholds.bad,
averageMessage: average
? i18n.translate('xpack.apm.rum.coreVitals.averageMessage', {
? i18n.translate('xpack.observability.ux.coreVitals.averageMessage', {
defaultMessage: ' and less than {bad}',
values: { bad: thresholds.bad },
})
@ -64,13 +59,7 @@ export function getCoreVitalTooltipMessage(
});
}
export function CoreVitalItem({
loading,
title,
value,
thresholds,
ranks = [100, 0, 0],
}: Props) {
export function CoreVitalItem({ loading, title, value, thresholds, ranks = [100, 0, 0] }: Props) {
const palette = euiPaletteForStatus(3);
const [inFocusInd, setInFocusInd] = useState<number | null>(null);
@ -100,12 +89,7 @@ export function CoreVitalItem({
position={ind}
inFocus={inFocusInd !== ind && inFocusInd !== null}
percentage={ranks[ind]}
tooltip={getCoreVitalTooltipMessage(
thresholds,
ind,
title,
ranks[ind]
)}
tooltip={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind])}
/>
))}
</EuiFlexGroup>

View file

@ -0,0 +1,86 @@
/*
* 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 * as React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { CLS_LABEL, FID_LABEL, LCP_LABEL } from './translations';
import { CoreVitalItem } from './core_vital_item';
import { WebCoreVitalsTitle } from './web_core_vitals_title';
import { ServiceName } from './service_name';
export interface UXMetrics {
cls: string;
fid: number;
lcp: number;
tbt: number;
fcp: number;
lcpRanks: number[];
fidRanks: number[];
clsRanks: number[];
}
export function formatToSec(value?: number | string, fromUnit = 'MicroSec'): string {
const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1);
if (valueInMs < 1000) {
return valueInMs.toFixed(0) + ' ms';
}
return (valueInMs / 1000).toFixed(2) + ' s';
}
const CoreVitalsThresholds = {
LCP: { good: '2.5s', bad: '4.0s' },
FID: { good: '100ms', bad: '300ms' },
CLS: { good: '0.1', bad: '0.25' },
};
interface Props {
loading: boolean;
data?: UXMetrics | null;
displayServiceName?: boolean;
serviceName?: string;
}
export function CoreVitals({ data, loading, displayServiceName, serviceName }: Props) {
const { lcp, lcpRanks, fid, fidRanks, cls, clsRanks } = data || {};
return (
<>
<WebCoreVitalsTitle />
<EuiSpacer size="s" />
{displayServiceName && <ServiceName name={serviceName!} />}
<EuiSpacer size="s" />
<EuiFlexGroup gutterSize="xl" justifyContent={'spaceBetween'} wrap>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={LCP_LABEL}
value={formatToSec(lcp, 'ms')}
ranks={lcpRanks}
loading={loading}
thresholds={CoreVitalsThresholds.LCP}
/>
</EuiFlexItem>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={FID_LABEL}
value={formatToSec(fid, 'ms')}
ranks={fidRanks}
loading={loading}
thresholds={CoreVitalsThresholds.FID}
/>
</EuiFlexItem>
<EuiFlexItem style={{ flexBasis: 380 }}>
<CoreVitalItem
title={CLS_LABEL}
value={cls ?? '0'}
ranks={clsRanks}
loading={loading}
thresholds={CoreVitalsThresholds.CLS}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}

View file

@ -17,8 +17,8 @@ import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
import { getCoreVitalTooltipMessage, Thresholds } from './CoreVitalItem';
import { useUiSetting$ } from '../../../../../../../../src/plugins/kibana_react/public';
import { getCoreVitalTooltipMessage, Thresholds } from './core_vital_item';
import { useUiSetting$ } from '../../../../../../../src/plugins/kibana_react/public';
import {
LEGEND_NEEDS_IMPROVEMENT_LABEL,
LEGEND_GOOD_LABEL,
@ -37,9 +37,7 @@ const StyledSpan = styled.span<{
}>`
&:hover {
background-color: ${(props) =>
props.darkMode
? euiDarkVars.euiColorLightestShade
: euiLightVars.euiColorLightestShade};
props.darkMode ? euiDarkVars.euiColorLightestShade : euiLightVars.euiColorLightestShade};
}
`;
@ -50,20 +48,11 @@ interface Props {
title: string;
}
export function PaletteLegends({
ranks,
title,
onItemHover,
thresholds,
}: Props) {
export function PaletteLegends({ ranks, title, onItemHover, thresholds }: Props) {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
const palette = euiPaletteForStatus(3);
const labels = [
LEGEND_GOOD_LABEL,
LEGEND_NEEDS_IMPROVEMENT_LABEL,
LEGEND_POOR_LABEL,
];
const labels = [LEGEND_GOOD_LABEL, LEGEND_NEEDS_IMPROVEMENT_LABEL, LEGEND_POOR_LABEL];
return (
<EuiFlexGroup responsive={false} gutterSize="s">
@ -79,19 +68,14 @@ export function PaletteLegends({
}}
>
<EuiToolTip
content={getCoreVitalTooltipMessage(
thresholds,
ind,
title,
ranks[ind]
)}
content={getCoreVitalTooltipMessage(thresholds, ind, title, ranks[ind])}
position="bottom"
>
<StyledSpan darkMode={darkMode}>
<PaletteLegend color={color}>
<EuiText size="xs">
<FormattedMessage
id="xpack.apm.rum.coreVitals.paletteLegend.rankPercentage"
id="xpack.observability.ux.coreVitals.paletteLegend.rankPercentage"
defaultMessage="{labelsInd} ({ranksInd}%)"
values={{
labelsInd: labels[ind],

View file

@ -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 React from 'react';
import { EuiIconTip, EuiText, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface Props {
name: string;
}
const SERVICE_LABEL = i18n.translate('xpack.observability.ux.coreWebVitals.service', {
defaultMessage: 'Service',
});
const SERVICE_LABEL_HELP = i18n.translate('xpack.observability.ux.service.help', {
defaultMessage: 'The RUM service with the most traffic is selected',
});
export function ServiceName({ name }: Props) {
return (
<>
<EuiText size="s">
{SERVICE_LABEL}
<EuiIconTip
color="text"
aria-label={SERVICE_LABEL_HELP}
type="questionInCircle"
content={SERVICE_LABEL_HELP}
/>
</EuiText>
<EuiTitle size="s">
<h3>{name}</h3>
</EuiTitle>
</>
);
}

View file

@ -0,0 +1,54 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const LCP_LABEL = i18n.translate('xpack.observability.ux.coreVitals.lcp', {
defaultMessage: 'Largest contentful paint',
});
export const FID_LABEL = i18n.translate('xpack.observability.ux.coreVitals.fip', {
defaultMessage: 'First input delay',
});
export const CLS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.cls', {
defaultMessage: 'Cumulative layout shift',
});
export const CV_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.poor', {
defaultMessage: 'a poor',
});
export const CV_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.good', {
defaultMessage: 'a good',
});
export const CV_AVERAGE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.average', {
defaultMessage: 'an average',
});
export const LEGEND_POOR_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.poor', {
defaultMessage: 'Poor',
});
export const LEGEND_GOOD_LABEL = i18n.translate('xpack.observability.ux.coreVitals.legends.good', {
defaultMessage: 'Good',
});
export const LEGEND_NEEDS_IMPROVEMENT_LABEL = i18n.translate(
'xpack.observability.ux.coreVitals.legends.needsImprovement',
{
defaultMessage: 'Needs improvement',
}
);
export const MORE_LABEL = i18n.translate('xpack.observability.ux.coreVitals.more', {
defaultMessage: 'more',
});
export const LESS_LABEL = i18n.translate('xpack.observability.ux.coreVitals.less', {
defaultMessage: 'less',
});

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { EuiButtonIcon, EuiLink, EuiPopover, EuiText, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
const CORE_WEB_VITALS = i18n.translate('xpack.observability.ux.coreWebVitals', {
defaultMessage: 'Core web vitals',
});
export function WebCoreVitalsTitle() {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = () => setIsPopoverOpen(false);
return (
<EuiTitle size="xs">
<h3>
{CORE_WEB_VITALS}
<EuiPopover
isOpen={isPopoverOpen}
button={
<EuiButtonIcon
onClick={() => setIsPopoverOpen(true)}
color={'text'}
iconType={'questionInCircle'}
/>
}
closePopover={closePopover}
>
<div>
<EuiText>
<FormattedMessage
id="xpack.observability.ux.dashboard.webCoreVitals.help"
defaultMessage="Learn more about"
/>
<EuiLink href="https://web.dev/vitals/" external target="_blank">
{' '}
{CORE_WEB_VITALS}
</EuiLink>
</EuiText>
</div>
</EuiPopover>
</h3>
</EuiTitle>
);
}

View file

@ -5,10 +5,10 @@
*/
import { createContext } from 'react';
import { AppMountContext } from 'kibana/public';
import { CoreStart } from 'kibana/public';
export interface PluginContextValue {
core: AppMountContext['core'];
core: CoreStart;
}
export const PluginContext = createContext({} as PluginContextValue);

View file

@ -15,6 +15,7 @@ import {
LogsFetchDataResponse,
MetricsFetchDataResponse,
UptimeFetchDataResponse,
UxFetchDataResponse,
} from './typings';
const params = {
@ -273,6 +274,60 @@ describe('registerDataHandler', () => {
expect(hasData).toBeTruthy();
});
});
describe('Ux', () => {
registerDataHandler({
appName: 'ux',
fetchData: async () => {
return {
title: 'User Experience',
appLink: '/ux',
coreWebVitals: {
cls: '0.01',
fid: 5,
lcp: 1464.3333333333333,
tbt: 232.92166666666665,
fcp: 1154.8,
lcpRanks: [73, 16, 11],
fidRanks: [85, 4, 11],
clsRanks: [88, 7, 5],
},
};
},
hasData: async () => ({ hasData: true, serviceName: 'elastic-co-frontend' }),
});
it('registered data handler', () => {
const dataHandler = getDataHandler('ux');
expect(dataHandler?.fetchData).toBeDefined();
expect(dataHandler?.hasData).toBeDefined();
});
it('returns data when fetchData is called', async () => {
const dataHandler = getDataHandler('ux');
const response = await dataHandler?.fetchData(params);
expect(response).toEqual({
title: 'User Experience',
appLink: '/ux',
coreWebVitals: {
cls: '0.01',
fid: 5,
lcp: 1464.3333333333333,
tbt: 232.92166666666665,
fcp: 1154.8,
lcpRanks: [73, 16, 11],
fidRanks: [85, 4, 11],
clsRanks: [88, 7, 5],
},
});
});
it('returns true when hasData is called', async () => {
const dataHandler = getDataHandler('ux');
const hasData = await dataHandler?.hasData();
expect(hasData).toBeTruthy();
});
});
describe('Metrics', () => {
registerDataHandler({
appName: 'infra_metrics',
@ -396,6 +451,7 @@ describe('registerDataHandler', () => {
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
unregisterDataHandler({ appName: 'ux' });
registerDataHandler({
appName: 'apm',
@ -425,11 +481,19 @@ describe('registerDataHandler', () => {
throw new Error('BOOM');
},
});
expect(await fetchHasData()).toEqual({
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 () => {
@ -437,6 +501,7 @@ describe('registerDataHandler', () => {
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
unregisterDataHandler({ appName: 'ux' });
registerDataHandler({
appName: 'apm',
@ -462,11 +527,19 @@ describe('registerDataHandler', () => {
throw new Error('BOOM');
},
});
expect(await fetchHasData()).toEqual({
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 () => {
@ -474,6 +547,7 @@ describe('registerDataHandler', () => {
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
unregisterDataHandler({ appName: 'ux' });
registerDataHandler({
appName: 'apm',
@ -495,11 +569,23 @@ describe('registerDataHandler', () => {
fetchData: async () => (({} as unknown) as UptimeFetchDataResponse),
hasData: async () => true,
});
expect(await fetchHasData()).toEqual({
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 () => {
@ -507,6 +593,7 @@ describe('registerDataHandler', () => {
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
unregisterDataHandler({ appName: 'ux' });
registerDataHandler({
appName: 'apm',
@ -528,11 +615,17 @@ describe('registerDataHandler', () => {
fetchData: async () => (({} as unknown) as UptimeFetchDataResponse),
hasData: async () => false,
});
expect(await fetchHasData()).toEqual({
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 () => {
@ -540,12 +633,14 @@ describe('registerDataHandler', () => {
unregisterDataHandler({ appName: 'infra_logs' });
unregisterDataHandler({ appName: 'infra_metrics' });
unregisterDataHandler({ appName: 'uptime' });
unregisterDataHandler({ appName: 'ux' });
expect(await fetchHasData()).toEqual({
expect(await fetchHasData({ end: 1601632271769, start: 1601631371769 })).toEqual({
apm: false,
uptime: false,
infra_logs: false,
infra_metrics: false,
ux: false,
});
});
});

View file

@ -4,7 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { DataHandler, ObservabilityFetchDataPlugins } from './typings/fetch_overview_data';
import {
DataHandler,
HasDataResponse,
ObservabilityFetchDataPlugins,
} from './typings/fetch_overview_data';
const dataHandlers: Partial<Record<ObservabilityFetchDataPlugins, DataHandler>> = {};
@ -31,14 +35,26 @@ export function getDataHandler<T extends ObservabilityFetchDataPlugins>(appName:
}
}
export async function fetchHasData(): Promise<Record<ObservabilityFetchDataPlugins, boolean>> {
const apps: ObservabilityFetchDataPlugins[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics'];
export async function fetchHasData(absoluteTime: {
start: number;
end: number;
}): Promise<Record<ObservabilityFetchDataPlugins, HasDataResponse>> {
const apps: ObservabilityFetchDataPlugins[] = [
'apm',
'uptime',
'infra_logs',
'infra_metrics',
'ux',
];
const promises = apps.map(async (app) => getDataHandler(app)?.hasData() || false);
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] = results.map((result) => {
const [apm, uptime, logs, metrics, ux] = results.map((result) => {
if (result.status === 'fulfilled') {
return result.value;
}
@ -50,6 +66,7 @@ export async function fetchHasData(): Promise<Record<ObservabilityFetchDataPlugi
return {
apm,
uptime,
ux,
infra_logs: logs,
infra_metrics: metrics,
};

View file

@ -0,0 +1,31 @@
/*
* 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 { useLocation } from 'react-router-dom';
import { useMemo } from 'react';
import { parse } from 'query-string';
import { UI_SETTINGS, useKibanaUISettings } from './use_kibana_ui_settings';
import { TimePickerTime } from '../components/shared/data_picker';
import { getAbsoluteTime } from '../utils/date';
const getParsedParams = (search: string) => {
return search ? parse(search[0] === '?' ? search.slice(1) : search, { sort: false }) : {};
};
export function useQueryParams() {
const { from, to } = useKibanaUISettings<TimePickerTime>(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
const { rangeFrom, rangeTo } = getParsedParams(useLocation().search);
return useMemo(() => {
return {
start: (rangeFrom as string) ?? from,
end: (rangeTo as string) ?? to,
absStart: getAbsoluteTime((rangeFrom as string) ?? from)!,
absEnd: getAbsoluteTime((rangeTo as string) ?? to, { roundUp: true })!,
};
}, [rangeFrom, rangeTo, from, to]);
}

View file

@ -17,6 +17,8 @@ export const plugin: PluginInitializer<ObservabilityPluginSetup, ObservabilityPl
export * from './components/shared/action_menu/';
export { UXMetrics, CoreVitals, formatToSec } from './components/shared/core_web_vitals/';
export {
useTrackPageview,
useUiTracker,

View file

@ -7,10 +7,19 @@ 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 { LoadingObservability } from '../overview/loading_observability';
export function HomePage() {
const history = useHistory();
const { data = {} } = useFetcher(() => fetchHasData(), []);
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;
@ -24,5 +33,5 @@ export function HomePage() {
}
}, [hasSomeData, history]);
return <></>;
return <LoadingObservability />;
}

View file

@ -0,0 +1,80 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
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';
interface Props {
bucketSize: string;
absoluteTime: { start?: number; end?: number };
relativeTime: { start: string; end: string };
hasData: Record<ObservabilityFetchDataPlugins, HasDataResponse>;
}
export function DataSections({ bucketSize, hasData, absoluteTime, relativeTime }: Props) {
return (
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column">
{hasData?.infra_logs && (
<EuiFlexItem grow={false}>
<LogsSection
bucketSize={bucketSize}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
/>
</EuiFlexItem>
)}
{hasData?.infra_metrics && (
<EuiFlexItem grow={false}>
<MetricsSection
bucketSize={bucketSize}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
/>
</EuiFlexItem>
)}
{hasData?.apm && (
<EuiFlexItem grow={false}>
<APMSection
bucketSize={bucketSize}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
/>
</EuiFlexItem>
)}
{hasData?.uptime && (
<EuiFlexItem grow={false}>
<UptimeSection
bucketSize={bucketSize}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
/>
</EuiFlexItem>
)}
{hasData?.ux && (
<EuiFlexItem grow={false}>
<UXSection
serviceName={(hasData.ux as UXHasDataResponse).serviceName as string}
bucketSize={bucketSize}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
);
}

View file

@ -69,6 +69,21 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I
}),
href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'),
},
{
id: 'ux',
title: i18n.translate('xpack.observability.emptySection.apps.ux.title', {
defaultMessage: 'User Experience',
}),
icon: 'logoAPM',
description: i18n.translate('xpack.observability.emptySection.apps.ux.description', {
defaultMessage:
'Performance is a distribution. Measure the experiences of all visitors to your web application and understand how to improve the experience for everyone.',
}),
linkTitle: i18n.translate('xpack.observability.emptySection.apps.ux.link', {
defaultMessage: 'Install Rum Agent',
}),
href: core.http.basePath.prepend('/app/home#/tutorial/apm'),
},
{
id: 'alert',
title: i18n.translate('xpack.observability.emptySection.apps.alert.title', {

View file

@ -8,43 +8,52 @@ import React, { useContext } from 'react';
import { ThemeContext } from 'styled-components';
import { EmptySection } from '../../components/app/empty_section';
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 { APMSection } from '../../components/app/section/apm';
import { LogsSection } from '../../components/app/section/logs';
import { MetricsSection } from '../../components/app/section/metrics';
import { UptimeSection } from '../../components/app/section/uptime';
import { DatePicker, TimePickerTime } from '../../components/shared/data_picker';
import { NewsFeed } from '../../components/app/news_feed';
import { fetchHasData } from '../../data_handler';
import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher';
import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_settings';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useTrackPageview } from '../../hooks/use_track_metric';
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 { getEmptySections } from './empty_section';
import { LoadingObservability } from './loading_observability';
import { getNewsFeed } from '../../services/get_news_feed';
import { DataSections } from './data_sections';
import { useTrackPageview } from '../..';
interface Props {
routeParams: RouteParams<'/overview'>;
}
function calculatetBucketSize({ start, end }: { start?: number; end?: number }) {
function calculateBucketSize({ start, end }: { start?: number; end?: number }) {
if (start && end) {
return getBucketSize({ start, end, minInterval: '60s' });
}
}
export function OverviewPage({ routeParams }: Props) {
const { core } = usePluginContext();
const timePickerTime = useKibanaUISettings<TimePickerTime>(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
const relativeTime = {
start: routeParams.query.rangeFrom ?? timePickerTime.from,
end: routeParams.query.rangeTo ?? timePickerTime.to,
};
const absoluteTime = {
start: getAbsoluteTime(relativeTime.start) as number,
end: getAbsoluteTime(relativeTime.end, { roundUp: true }) as number,
};
useTrackPageview({ app: 'observability', path: 'overview' });
useTrackPageview({ app: 'observability', path: 'overview', delay: 15000 });
const { core } = usePluginContext();
const { data: alerts = [], status: alertStatus } = useFetcher(() => {
return getObservabilityAlerts({ core });
}, [core]);
@ -52,9 +61,12 @@ export function OverviewPage({ routeParams }: Props) {
const { data: newsFeed } = useFetcher(() => getNewsFeed({ core }), [core]);
const theme = useContext(ThemeContext);
const timePickerTime = useKibanaUISettings<TimePickerTime>(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS);
const result = useFetcher(() => fetchHasData(), []);
const result = useFetcher(
() => fetchHasData(absoluteTime),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const hasData = result.data;
if (!hasData) {
@ -63,17 +75,7 @@ export function OverviewPage({ routeParams }: Props) {
const { refreshInterval = 10000, refreshPaused = true } = routeParams.query;
const relativeTime = {
start: routeParams.query.rangeFrom ?? timePickerTime.from,
end: routeParams.query.rangeTo ?? timePickerTime.to,
};
const absoluteTime = {
start: getAbsoluteTime(relativeTime.start),
end: getAbsoluteTime(relativeTime.end, { roundUp: true }),
};
const bucketSize = calculatetBucketSize({
const bucketSize = calculateBucketSize({
start: absoluteTime.start,
end: absoluteTime.end,
});
@ -117,46 +119,12 @@ export function OverviewPage({ routeParams }: Props) {
<EuiFlexItem grow={6}>
{/* Data sections */}
{showDataSections && (
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column">
{hasData.infra_logs && (
<EuiFlexItem grow={false}>
<LogsSection
absoluteTime={absoluteTime}
relativeTime={relativeTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.infra_metrics && (
<EuiFlexItem grow={false}>
<MetricsSection
absoluteTime={absoluteTime}
relativeTime={relativeTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.apm && (
<EuiFlexItem grow={false}>
<APMSection
absoluteTime={absoluteTime}
relativeTime={relativeTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
{hasData.uptime && (
<EuiFlexItem grow={false}>
<UptimeSection
absoluteTime={absoluteTime}
relativeTime={relativeTime}
bucketSize={bucketSize?.intervalString}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<DataSections
hasData={hasData}
absoluteTime={absoluteTime}
relativeTime={relativeTime}
bucketSize={bucketSize?.intervalString!}
/>
)}
{/* Empty sections */}

View file

@ -6,7 +6,7 @@
import { makeDecorator } from '@storybook/addons';
import { storiesOf } from '@storybook/react';
import { AppMountContext } from 'kibana/public';
import { CoreStart } from 'kibana/public';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { UI_SETTINGS } from '../../../../../../src/plugins/data/public';
@ -36,7 +36,7 @@ const withCore = makeDecorator({
return (
<MemoryRouter>
<PluginContext.Provider value={{ core: options as AppMountContext['core'] }}>
<PluginContext.Provider value={{ core: options as CoreStart }}>
<EuiThemeProvider>{storyFn(context)}</EuiThemeProvider>
</PluginContext.Provider>
</MemoryRouter>
@ -119,7 +119,7 @@ const core = ({
return euiSettings[key];
},
},
} as unknown) as AppMountContext['core'];
} as unknown) as CoreStart;
const coreWithAlerts = ({
...core,
@ -127,7 +127,7 @@ const coreWithAlerts = ({
...core.http,
get: alertsFetchData,
},
} as unknown) as AppMountContext['core'];
} as unknown) as CoreStart;
const coreWithNewsFeed = ({
...core,
@ -135,7 +135,7 @@ const coreWithNewsFeed = ({
...core.http,
get: newsFeedFetchData,
},
} as unknown) as AppMountContext['core'];
} as unknown) as CoreStart;
const coreAlertsThrowsError = ({
...core,
@ -145,7 +145,7 @@ const coreAlertsThrowsError = ({
throw new Error('Error fetching Alerts data');
},
},
} as unknown) as AppMountContext['core'];
} as unknown) as CoreStart;
storiesOf('app/Overview', module)
.addDecorator(withCore(core))

View file

@ -5,6 +5,7 @@
*/
import { ObservabilityApp } from '../../../typings/common';
import { UXMetrics } from '../../components/shared/core_web_vitals';
export interface Stat {
type: 'number' | 'percent' | 'bytesPerSecond';
@ -24,17 +25,29 @@ export interface FetchDataParams {
absoluteTime: { start: number; end: number };
relativeTime: { start: string; end: string };
bucketSize: string;
serviceName?: string;
}
export interface HasDataParams {
absoluteTime: { start: number; end: number };
}
export interface UXHasDataResponse {
hasData: boolean;
serviceName: string | number | undefined;
}
export type HasDataResponse = UXHasDataResponse | boolean;
export type FetchData<T extends FetchDataResponse = FetchDataResponse> = (
fetchDataParams: FetchDataParams
) => Promise<T>;
export type HasData = () => Promise<boolean>;
export type HasData = (params?: HasDataParams) => Promise<HasDataResponse>;
export type ObservabilityFetchDataPlugins = Exclude<
ObservabilityApp,
'observability' | 'stack_monitoring' | 'ux'
'observability' | 'stack_monitoring'
>;
export interface DataHandler<
@ -89,9 +102,14 @@ export interface ApmFetchDataResponse extends FetchDataResponse {
};
}
export interface UxFetchDataResponse extends FetchDataResponse {
coreWebVitals: UXMetrics;
}
export interface ObservabilityFetchDataResponse {
apm: ApmFetchDataResponse;
infra_metrics: MetricsFetchDataResponse;
infra_logs: LogsFetchDataResponse;
uptime: UptimeFetchDataResponse;
ux: UxFetchDataResponse;
}

View file

@ -5,9 +5,11 @@
*/
import React from 'react';
import { render as testLibRender } from '@testing-library/react';
import { AppMountContext } from 'kibana/public';
import { CoreStart } from 'kibana/public';
import { of } from 'rxjs';
import { PluginContext } from '../context/plugin_context';
import { EuiThemeProvider } from '../typings';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
export const core = ({
http: {
@ -15,12 +17,18 @@ export const core = ({
prepend: jest.fn(),
},
},
} as unknown) as AppMountContext['core'];
uiSettings: {
get: (key: string) => true,
get$: (key: string) => of(true),
},
} as unknown) as CoreStart;
export const render = (component: React.ReactNode) => {
return testLibRender(
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{component}</EuiThemeProvider>
</PluginContext.Provider>
<KibanaContextProvider services={{ ...core }}>
<PluginContext.Provider value={{ core }}>
<EuiThemeProvider>{component}</EuiThemeProvider>
</PluginContext.Provider>
</KibanaContextProvider>
);
};

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { expectSnapshot } from '../../../common/match_snapshot';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
export default function rumHasDataApiTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('CSM has rum data api', () => {
describe('when there is no data', () => {
it('returns empty list', async () => {
const response = await supertest.get(
'/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters='
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"hasData": false,
}
`);
});
});
describe('when there is data', () => {
before(async () => {
await esArchiver.load('8.0.0');
await esArchiver.load('rum_8.0.0');
});
after(async () => {
await esArchiver.unload('8.0.0');
await esArchiver.unload('rum_8.0.0');
});
it('returns that it has data and service name with most traffice', async () => {
const response = await supertest.get(
'/api/apm/observability_overview/has_rum_data?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters='
);
expect(response.status).to.be(200);
expectSnapshot(response.body).toMatchInline(`
Object {
"hasData": true,
"serviceName": "client",
}
`);
});
});
});
}

View file

@ -39,6 +39,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr
loadTestFile(require.resolve('./csm/url_search.ts'));
loadTestFile(require.resolve('./csm/page_views.ts'));
loadTestFile(require.resolve('./csm/js_errors.ts'));
loadTestFile(require.resolve('./csm/has_rum_data.ts'));
});
});
}