mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[UX] Add core web vitals in obsv homepage (#78976)
This commit is contained in:
parent
28278abdda
commit
de130abfbc
38 changed files with 1032 additions and 390 deletions
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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({
|
||||
|
|
59
x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts
Normal file
59
x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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],
|
||||
},
|
||||
};
|
|
@ -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() },
|
|
@ -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) {
|
|
@ -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>
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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],
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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', {
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue