[APM] useFetcher: Replace react-redux-request with hooks and Context API (#33392)

This commit is contained in:
Søren Louv-Jansen 2019-03-28 14:56:56 +01:00 committed by GitHub
parent a7062883d2
commit 30c63f57fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 3122 additions and 2921 deletions

View file

@ -293,6 +293,7 @@
"@types/json5": "^0.0.30",
"@types/listr": "^0.13.0",
"@types/lodash": "^3.10.1",
"@types/lru-cache": "^5.1.0",
"@types/minimatch": "^2.0.29",
"@types/mocha": "^5.2.6",
"@types/moment-timezone": "^0.5.8",

View file

@ -44,8 +44,8 @@
"@types/d3-array": "^1.2.1",
"@types/d3-scale": "^2.0.0",
"@types/d3-shape": "^1.3.1",
"@types/d3-time": "^1.0.7",
"@types/d3-time-format": "^2.1.0",
"@types/d3-time": "^1.0.7",
"@types/elasticsearch": "^5.0.30",
"@types/graphql": "^0.13.1",
"@types/history": "^4.6.2",
@ -55,12 +55,13 @@
"@types/jsonwebtoken": "^7.2.7",
"@types/lodash": "^3.10.1",
"@types/mocha": "^5.2.6",
"@types/object-hash": "^1.2.0",
"@types/pngjs": "^3.3.1",
"@types/prop-types": "^15.5.3",
"@types/react": "^16.8.0",
"@types/react-dom": "^16.8.0",
"@types/react-redux": "^6.0.6",
"@types/react-router-dom": "^4.3.1",
"@types/react": "^16.8.0",
"@types/recompose": "^0.30.2",
"@types/reduce-reducers": "^0.1.3",
"@types/sinon": "^5.0.1",
@ -115,7 +116,9 @@
"proxyquire": "1.7.11",
"react-docgen-typescript-loader": "^3.0.0",
"react-docgen-typescript-webpack-plugin": "^1.1.0",
"react-hooks-testing-library": "^0.3.8",
"react-test-renderer": "^16.8.0",
"react-testing-library": "^6.0.0",
"redux-test-utils": "0.2.2",
"rsync": "0.4.0",
"run-sequence": "^2.2.1",
@ -226,6 +229,7 @@
"ngreact": "^0.5.1",
"node-fetch": "^2.1.2",
"nodemailer": "^4.6.4",
"object-hash": "^1.3.1",
"object-path-immutable": "^0.5.3",
"oppsy": "^2.0.0",
"papaparse": "^4.6.0",

View file

@ -15,10 +15,14 @@ function getUiSettingsClient() {
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
},
};
}
function getBasePath() {
return '/some/base/path';
}
function addBasePath(path) {
return path;
}
@ -43,6 +47,7 @@ function getXsrfToken() {
export default {
getInjected,
addBasePath,
getBasePath,
getUiSettingsClient,
getXsrfToken
getXsrfToken,
};

View file

@ -7,8 +7,6 @@
import { shallow } from 'enzyme';
import { Location } from 'history';
import React from 'react';
import { RRRRenderResponse } from 'react-redux-request';
import { ErrorGroupAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_group';
import { APMError } from 'x-pack/plugins/apm/typings/es_schemas/ui/APMError';
import { mockMoment } from '../../../../utils/testHelpers';
import { DetailView } from './index';
@ -31,21 +29,17 @@ describe('DetailView', () => {
});
it('should render Discover button', () => {
const errorGroup: RRRRenderResponse<ErrorGroupAPIResponse> = {
args: [],
status: 'SUCCESS',
data: {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
http: { request: { method: 'GET' } },
url: { full: 'myUrl' },
service: { name: 'myService' },
user: { id: 'myUserId' },
error: { exception: { handled: true } },
transaction: { id: 'myTransactionId', sampled: true }
} as unknown) as APMError
}
const errorGroup = {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
http: { request: { method: 'GET' } },
url: { full: 'myUrl' },
service: { name: 'myService' },
user: { id: 'myUserId' },
error: { exception: { handled: true } },
transaction: { id: 'myTransactionId', sampled: true }
} as unknown) as APMError
};
const wrapper = shallow(
<DetailView
@ -60,13 +54,9 @@ describe('DetailView', () => {
});
it('should render StickyProperties', () => {
const errorGroup: RRRRenderResponse<ErrorGroupAPIResponse> = {
args: [],
status: 'SUCCESS',
data: {
occurrencesCount: 10,
error: {} as APMError
}
const errorGroup = {
occurrencesCount: 10,
error: {} as APMError
};
const wrapper = shallow(
<DetailView
@ -80,17 +70,13 @@ describe('DetailView', () => {
});
it('should render tabs', () => {
const errorGroup: RRRRenderResponse<ErrorGroupAPIResponse> = {
args: [],
status: 'SUCCESS',
data: {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
service: {},
user: {}
} as unknown) as APMError
}
const errorGroup = {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
service: {},
user: {}
} as unknown) as APMError
};
const wrapper = shallow(
<DetailView
@ -105,16 +91,12 @@ describe('DetailView', () => {
});
it('should render TabContent', () => {
const errorGroup: RRRRenderResponse<ErrorGroupAPIResponse> = {
args: [],
status: 'SUCCESS',
data: {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
context: {}
} as unknown) as APMError
}
const errorGroup = {
occurrencesCount: 10,
error: ({
'@timestamp': 'myTimestamp',
context: {}
} as unknown) as APMError
};
const wrapper = shallow(
<DetailView

View file

@ -16,7 +16,6 @@ import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { get } from 'lodash';
import React from 'react';
import { RRRRenderResponse } from 'react-redux-request';
import styled from 'styled-components';
import { idx } from 'x-pack/plugins/apm/common/idx';
import {
@ -24,7 +23,6 @@ import {
history,
toQuery
} from 'x-pack/plugins/apm/public/components/shared/Links/url_helpers';
import { STATUS } from 'x-pack/plugins/apm/public/constants';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { ErrorGroupAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_group';
import { APMError } from 'x-pack/plugins/apm/typings/es_schemas/ui/APMError';
@ -49,16 +47,13 @@ const HeaderContainer = styled.div`
`;
interface Props {
errorGroup: RRRRenderResponse<ErrorGroupAPIResponse>;
errorGroup: ErrorGroupAPIResponse;
urlParams: IUrlParams;
location: Location;
}
export function DetailView({ errorGroup, urlParams, location }: Props) {
if (errorGroup.status !== STATUS.SUCCESS) {
return null;
}
const { transaction, error, occurrencesCount } = errorGroup.data;
const { transaction, error, occurrencesCount } = errorGroup;
if (!error) {
return null;

View file

@ -20,8 +20,11 @@ import React, { Fragment } from 'react';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from 'x-pack/plugins/apm/common/i18n';
import { idx } from 'x-pack/plugins/apm/common/idx';
import { ErrorDistributionRequest } from '../../../store/reactReduxRequest/errorDistribution';
import { ErrorGroupDetailsRequest } from '../../../store/reactReduxRequest/errorGroup';
import { useFetcher } from '../../../hooks/useFetcher';
import {
loadErrorDistribution,
loadErrorGroupDetails
} from '../../../services/rest/apm/error_groups';
import { IUrlParams } from '../../../store/urlParams';
import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables';
// @ts-ignore
@ -64,124 +67,117 @@ interface Props {
}
export function ErrorGroupDetailsView({ urlParams, location }: Props) {
const { serviceName, start, end, errorGroupId } = urlParams;
const { data: errorGroupData } = useFetcher(
() => loadErrorGroupDetails({ serviceName, start, end, errorGroupId }),
[serviceName, start, end, errorGroupId]
);
const { data: errorDistributionData } = useFetcher(
() => loadErrorDistribution({ serviceName, start, end }),
[serviceName, start, end]
);
if (!errorGroupData || !errorDistributionData) {
return null;
}
// If there are 0 occurrences, show only distribution chart w. empty message
const showDetails = errorGroupData.occurrencesCount !== 0;
const logMessage = idx(errorGroupData, _ => _.error.error.log.message);
const excMessage = idx(
errorGroupData,
_ => _.error.error.exception[0].message
);
const culprit = idx(errorGroupData, _ => _.error.error.culprit);
const isUnhandled =
idx(errorGroupData, _ => _.error.error.exception[0].handled) === false;
return (
<ErrorGroupDetailsRequest
urlParams={urlParams}
render={errorGroup => {
// If there are 0 occurrences, show only distribution chart w. empty message
const showDetails = errorGroup.data.occurrencesCount !== 0;
const logMessage = idx(errorGroup, _ => _.data.error.error.log.message);
const excMessage = idx(
errorGroup,
_ => _.data.error.error.exception[0].message
);
const culprit = idx(errorGroup, _ => _.data.error.error.culprit);
const isUnhandled =
idx(errorGroup, _ => _.data.error.error.exception[0].handled) ===
false;
<div>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>
{i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', {
defaultMessage: 'Error group {errorGroupId}',
values: {
errorGroupId: getShortGroupId(urlParams.errorGroupId)
}
})}
</h1>
</EuiTitle>
</EuiFlexItem>
{isUnhandled && (
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
{i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', {
defaultMessage: 'Unhandled'
})}
</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
return (
<div>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>
<EuiSpacer size="m" />
<FilterBar />
<EuiSpacer size="s" />
<EuiPanel>
{showDetails && (
<Titles>
<EuiText>
{logMessage && (
<Fragment>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.errorGroupTitle',
'xpack.apm.errorGroupDetails.logMessageLabel',
{
defaultMessage: 'Error group {errorGroupId}',
values: {
errorGroupId: getShortGroupId(urlParams.errorGroupId)
}
defaultMessage: 'Log message'
}
)}
</h1>
</EuiTitle>
</EuiFlexItem>
{isUnhandled && (
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
{i18n.translate(
'xpack.apm.errorGroupDetails.unhandledLabel',
{
defaultMessage: 'Unhandled'
}
)}
</EuiBadge>
</EuiFlexItem>
</Label>
<Message>{logMessage}</Message>
</Fragment>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<FilterBar />
<EuiSpacer size="s" />
<EuiPanel>
{showDetails && (
<Titles>
<EuiText>
{logMessage && (
<Fragment>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.logMessageLabel',
{
defaultMessage: 'Log message'
}
)}
</Label>
<Message>{logMessage}</Message>
</Fragment>
)}
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.exceptionMessageLabel',
{
defaultMessage: 'Exception message'
}
)}
</Label>
<Message>{excMessage || NOT_AVAILABLE_LABEL}</Message>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.culpritLabel',
{
defaultMessage: 'Culprit'
}
)}
</Label>
<Culprit>{culprit || NOT_AVAILABLE_LABEL}</Culprit>
</EuiText>
</Titles>
)}
<ErrorDistributionRequest
urlParams={urlParams}
render={({ data }) => (
<ErrorDistribution
distribution={data}
title={i18n.translate(
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
{
defaultMessage: 'Occurrences'
}
)}
/>
<Label>
{i18n.translate(
'xpack.apm.errorGroupDetails.exceptionMessageLabel',
{
defaultMessage: 'Exception message'
}
)}
/>
</EuiPanel>
<EuiSpacer />
{showDetails && (
<DetailView
errorGroup={errorGroup}
urlParams={urlParams}
location={location}
/>
)}
</div>
);
}}
/>
</Label>
<Message>{excMessage || NOT_AVAILABLE_LABEL}</Message>
<Label>
{i18n.translate('xpack.apm.errorGroupDetails.culpritLabel', {
defaultMessage: 'Culprit'
})}
</Label>
<Culprit>{culprit || NOT_AVAILABLE_LABEL}</Culprit>
</EuiText>
</Titles>
)}
<ErrorDistribution
distribution={errorDistributionData}
title={i18n.translate(
'xpack.apm.errorGroupDetails.occurrencesChartLabel',
{
defaultMessage: 'Occurrences'
}
)}
/>
</EuiPanel>
<EuiSpacer />
{showDetails && (
<DetailView
errorGroup={errorGroupData}
urlParams={urlParams}
location={location}
/>
)}
</div>
);
}

View file

@ -6,13 +6,13 @@
import { mount } from 'enzyme';
import { Location } from 'history';
import createHistory from 'history/createHashHistory';
import PropTypes from 'prop-types';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
mockMoment,
mountWithRouterAndStore,
toJson
} from '../../../../../utils/testHelpers';
// @ts-ignore
import { createMockStore } from 'redux-test-utils';
import { mockMoment, toJson } from '../../../../../utils/testHelpers';
import { ErrorGroupList } from '../index';
import props from './props.json';
@ -47,3 +47,30 @@ describe('ErrorGroupOverview -> List', () => {
expect(toJson(wrapper)).toMatchSnapshot();
});
});
export function mountWithRouterAndStore(
Component: React.ReactElement,
storeState = {}
) {
const store = createMockStore(storeState);
const history = createHistory();
const options = {
context: {
store,
router: {
history,
route: {
match: { path: '/', url: '/', params: {}, isExact: true },
location: { pathname: '/', search: '', hash: '', key: '4yyjf5' }
}
}
},
childContextTypes: {
store: PropTypes.object.isRequired,
router: PropTypes.object.isRequired
}
};
return mount(Component, options);
}

View file

@ -15,9 +15,12 @@ import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import { ErrorDistribution } from 'x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution';
import { ErrorDistributionRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/errorDistribution';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { ErrorGroupOverviewRequest } from '../../../store/reactReduxRequest/errorGroupList';
import { useFetcher } from '../../../hooks/useFetcher';
import {
loadErrorDistribution,
loadErrorGroupList
} from '../../../services/rest/apm/error_groups';
import { ErrorGroupList } from './List';
interface ErrorGroupOverviewProps {
@ -29,23 +32,50 @@ const ErrorGroupOverview: React.SFC<ErrorGroupOverviewProps> = ({
urlParams,
location
}) => {
const {
serviceName,
start,
end,
errorGroupId,
kuery,
sortField,
sortDirection
} = urlParams;
const { data: errorDistributionData } = useFetcher(
() =>
loadErrorDistribution({ serviceName, start, end, errorGroupId, kuery }),
[serviceName, start, end, errorGroupId, kuery]
);
const { data: errorGroupListData } = useFetcher(
() =>
loadErrorGroupList({
serviceName,
start,
end,
sortField,
sortDirection,
kuery
}),
[serviceName, start, end, sortField, sortDirection, kuery]
);
if (!errorDistributionData || !errorGroupListData) {
return null;
}
return (
<React.Fragment>
<EuiFlexGroup>
<EuiFlexItem>
<EuiPanel>
<ErrorDistributionRequest
urlParams={urlParams}
render={({ data }) => (
<ErrorDistribution
distribution={data}
title={i18n.translate(
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
{
defaultMessage: 'Error occurrences'
}
)}
/>
<ErrorDistribution
distribution={errorDistributionData}
title={i18n.translate(
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
{
defaultMessage: 'Error occurrences'
}
)}
/>
</EuiPanel>
@ -59,15 +89,11 @@ const ErrorGroupOverview: React.SFC<ErrorGroupOverviewProps> = ({
<h3>Errors</h3>
</EuiTitle>
<EuiSpacer size="s" />
<ErrorGroupOverviewRequest
<ErrorGroupList
urlParams={urlParams}
render={({ data }) => (
<ErrorGroupList
urlParams={urlParams}
items={data}
location={location}
/>
)}
items={errorGroupListData}
location={location}
/>
</EuiPanel>
</React.Fragment>

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 { EuiDelayHide, EuiPortal, EuiProgress } from '@elastic/eui';
import React, { Fragment, useMemo, useReducer } from 'react';
export const GlobalFetchContext = React.createContext({
statuses: {},
dispatchStatus: (action: Action) => undefined as void
});
interface State {
[key: string]: boolean;
}
interface Action {
isLoading: boolean;
name: string;
}
function reducer(statuses: State, action: Action) {
return { ...statuses, [action.name]: action.isLoading };
}
function getIsAnyLoading(statuses: State) {
return Object.values(statuses).some(isLoading => isLoading);
}
export function GlobalFetchIndicator({
children
}: {
children: React.ReactNode;
}) {
const [statuses, dispatchStatus] = useReducer(reducer, {});
const isLoading = useMemo(() => getIsAnyLoading(statuses), [statuses]);
return (
<Fragment>
<EuiDelayHide
hide={!isLoading}
minimumDuration={1000}
render={() => (
<EuiPortal>
<EuiProgress size="xs" position="fixed" />
</EuiPortal>
)}
/>
<GlobalFetchContext.Provider
value={{ statuses, dispatchStatus }}
children={children}
/>
</Fragment>
);
}

View file

@ -1,26 +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 { get, some } from 'lodash';
import { connect } from 'react-redux';
import { IReduxState } from 'x-pack/plugins/apm/public/store/rootReducer';
import { STATUS } from '../../../../constants/index';
import { GlobalProgressView } from './view';
function getIsLoading(state: IReduxState) {
return some(
state.reactReduxRequest,
subState => get(subState, 'status') === STATUS.LOADING
);
}
function mapStateToProps(state = {} as IReduxState) {
return {
isLoading: getIsLoading(state)
};
}
export const GlobalProgress = connect(mapStateToProps)(GlobalProgressView);

View file

@ -1,26 +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 { EuiDelayHide, EuiPortal, EuiProgress } from '@elastic/eui';
import React from 'react';
interface Props {
isLoading: boolean;
}
export function GlobalProgressView({ isLoading }: Props) {
return (
<EuiDelayHide
hide={!isLoading}
minimumDuration={1000}
render={() => (
<EuiPortal>
<EuiProgress size="xs" position="fixed" />
</EuiPortal>
)}
/>
);
}

View file

@ -4,21 +4,31 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { STATUS } from '../../../../constants/index';
import { LicenceRequest } from '../../../../store/reactReduxRequest/license';
import {
FETCH_STATUS,
useFetcher
} from 'x-pack/plugins/apm/public/hooks/useFetcher';
import { loadLicense } from 'x-pack/plugins/apm/public/services/rest/xpack';
import { InvalidLicenseNotification } from './InvalidLicenseNotification';
export const LicenseCheck: React.FunctionComponent = ({ children }) => {
return (
<LicenceRequest
render={({ data: licenseData, status: licenseStatus }) => {
const hasValidLicense = licenseData.license.is_active;
if (licenseStatus === STATUS.SUCCESS && !hasValidLicense) {
return <InvalidLicenseNotification />;
}
return children;
}}
/>
);
const initialLicense = {
features: {
watcher: { is_available: false },
ml: { is_available: false }
},
license: { is_active: false }
};
export const LicenseContext = React.createContext(initialLicense);
export const LicenseCheck: React.FC = ({ children }) => {
const { data = initialLicense, status } = useFetcher(() => loadLicense(), []);
const hasValidLicense = data.license.is_active;
// if license is invalid show an error message
if (status === FETCH_STATUS.SUCCESS && !hasValidLicense) {
return <InvalidLicenseNotification />;
}
// render rest of application and pass down license via context
return <LicenseContext.Provider value={data} children={children} />;
};

View file

@ -10,6 +10,7 @@ import styled from 'styled-components';
import { px, topNavHeight, unit, units } from '../../../style/variables';
// @ts-ignore
import ConnectRouterToRedux from '../../shared/ConnectRouterToRedux';
import { GlobalFetchIndicator } from './GlobalFetchIndicator';
import { LicenseCheck } from './LicenseCheck';
import { routes } from './routeConfig';
import { ScrollToTopOnPathChange } from './ScrollToTopOnPathChange';
@ -23,17 +24,19 @@ const MainContainer = styled.div`
export function Main() {
return (
<MainContainer>
<UpdateBreadcrumbs />
<Route component={ConnectRouterToRedux} />
<Route component={ScrollToTopOnPathChange} />
<LicenseCheck>
<Switch>
{routes.map((route, i) => (
<Route key={i} {...route} />
))}
</Switch>
</LicenseCheck>
</MainContainer>
<GlobalFetchIndicator>
<MainContainer>
<UpdateBreadcrumbs />
<Route component={ConnectRouterToRedux} />
<Route component={ScrollToTopOnPathChange} />
<LicenseCheck>
<Switch>
{routes.map((route, i) => (
<Route key={i} {...route} />
))}
</Switch>
</LicenseCheck>
</MainContainer>
</GlobalFetchIndicator>
);
}

View file

@ -11,11 +11,11 @@ import React from 'react';
import CustomPlot from 'x-pack/plugins/apm/public/components/shared/charts/CustomPlot';
import { HoverXHandlers } from 'x-pack/plugins/apm/public/components/shared/charts/SyncChartGroup';
import { asPercent } from 'x-pack/plugins/apm/public/utils/formatters';
import { CPUChartAPIResponse } from 'x-pack/plugins/apm/server/lib/metrics/get_cpu_chart_data/transformer';
import { Coordinate } from 'x-pack/plugins/apm/typings/timeseries';
import { CPUMetricSeries } from '../../../store/selectors/chartSelectors';
interface Props {
data: CPUChartAPIResponse;
data: CPUMetricSeries;
hoverXHandlers: HoverXHandlers;
}

View file

@ -11,11 +11,11 @@ import React from 'react';
import CustomPlot from 'x-pack/plugins/apm/public/components/shared/charts/CustomPlot';
import { HoverXHandlers } from 'x-pack/plugins/apm/public/components/shared/charts/SyncChartGroup';
import { asPercent } from 'x-pack/plugins/apm/public/utils/formatters';
import { MemoryChartAPIResponse } from 'x-pack/plugins/apm/server/lib/metrics/get_memory_chart_data/transformer';
import { Coordinate } from 'x-pack/plugins/apm/typings/timeseries';
import { MemoryMetricSeries } from '../../../store/selectors/chartSelectors';
interface Props {
data: MemoryChartAPIResponse;
data: MemoryMetricSeries;
hoverXHandlers: HoverXHandlers;
}

View file

@ -1,391 +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 {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
EuiSpacer,
EuiText,
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Location } from 'history';
import React, { Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { getMlJobId } from 'x-pack/plugins/apm/common/ml_job_constants';
import { KibanaLink } from 'x-pack/plugins/apm/public/components/shared/Links/KibanaLink';
import { MLJobLink } from 'x-pack/plugins/apm/public/components/shared/Links/MLJobLink';
import { startMLJob } from 'x-pack/plugins/apm/public/services/rest/ml';
import { getAPMIndexPattern } from 'x-pack/plugins/apm/public/services/rest/savedObjects';
import { MLJobsRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/machineLearningJobs';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { TransactionSelect } from './TransactionSelect';
interface FlyoutProps {
isOpen: boolean;
onClose: () => void;
urlParams: IUrlParams;
location: Location;
serviceTransactionTypes: string[];
}
interface FlyoutState {
isLoading: boolean;
hasMLJob: boolean;
hasIndexPattern: boolean;
selectedTransactionType?: string;
}
export class MachineLearningFlyout extends Component<FlyoutProps, FlyoutState> {
public state = {
isLoading: false,
hasIndexPattern: false,
hasMLJob: false,
selectedTransactionType: this.props.urlParams.transactionType
};
public willUnmount = false;
public componentWillUnmount() {
this.willUnmount = true;
}
public async componentDidMount() {
const indexPattern = await getAPMIndexPattern();
if (!this.willUnmount) {
this.setState({ hasIndexPattern: !!indexPattern });
}
}
// TODO: This should use `getDerivedStateFromProps`
public componentDidUpdate(prevProps: FlyoutProps) {
if (
prevProps.urlParams.transactionType !==
this.props.urlParams.transactionType
) {
this.setState({
selectedTransactionType: this.props.urlParams.transactionType
});
}
}
public createJob = async () => {
this.setState({ isLoading: true });
try {
const { serviceName, transactionType } = this.props.urlParams;
if (!serviceName || !transactionType) {
throw new Error(
'Service name and transaction type are required to create this ML job'
);
}
const res = await startMLJob({ serviceName, transactionType });
const didSucceed = res.datafeeds[0].success && res.jobs[0].success;
if (!didSucceed) {
throw new Error('Creating ML job failed');
}
this.addSuccessToast();
} catch (e) {
this.addErrorToast();
}
this.setState({ isLoading: false });
this.props.onClose();
};
public addErrorToast = () => {
const { urlParams } = this.props;
const { serviceName } = urlParams;
if (!serviceName) {
return;
}
toastNotifications.addWarning({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
{
defaultMessage: 'Job creation failed'
}
),
text: (
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText',
{
defaultMessage:
'Your current license may not allow for creating machine learning jobs, or this job may already exist.'
}
)}
</p>
)
});
};
public addSuccessToast = () => {
const { location, urlParams } = this.props;
const { serviceName, transactionType } = urlParams;
if (!serviceName) {
return;
}
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle',
{
defaultMessage: 'Job successfully created'
}
),
text: (
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText',
{
defaultMessage:
'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.',
values: {
serviceName,
transactionType
}
}
)}{' '}
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={location}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
{
defaultMessage: 'View job'
}
)}
</MLJobLink>
</p>
)
});
};
public render() {
const { isOpen, onClose, urlParams, location } = this.props;
const { serviceName, transactionType } = urlParams;
const { isLoading, hasIndexPattern, selectedTransactionType } = this.state;
if (!isOpen || !serviceName) {
return null;
}
return (
<MLJobsRequest
serviceName={serviceName}
render={({ data, status }) => {
if (status === 'LOADING') {
return null;
}
const hasMLJob = data.jobs.some(
job =>
job.job_id === getMlJobId(serviceName, selectedTransactionType)
);
return (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle>
<h2>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle',
{
defaultMessage: 'Enable anomaly detection'
}
)}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
</EuiFlyoutHeader>
<EuiFlyoutBody>
{hasMLJob && (
<div>
<EuiCallOut
title={i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle',
{
defaultMessage: 'Job already exists'
}
)}
color="success"
iconType="check"
>
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription',
{
defaultMessage:
'There is currently a job running for {serviceName} ({transactionType}).',
values: {
serviceName,
transactionType
}
}
)}{' '}
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={location}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText',
{
defaultMessage: 'View existing job'
}
)}
</MLJobLink>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</div>
)}
{!hasIndexPattern && (
<div>
<EuiCallOut
title={
<span>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.noPatternTitle"
defaultMessage="No APM index pattern available. To create a job, please import the APM index pattern via the {setupInstructionLink}"
values={{
setupInstructionLink: (
<KibanaLink
pathname={'/app/kibana'}
hash={`/home/tutorial/apm`}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.noPatternTitle.setupInstructionLinkText',
{
defaultMessage: 'Setup Instructions'
}
)}
</KibanaLink>
)
}}
/>
</span>
}
color="warning"
iconType="alert"
/>
<EuiSpacer size="m" />
</div>
)}
<EuiText>
<p>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription"
defaultMessage="Here you can create a machine learning job to calculate anomaly scores on durations for APM transactions
within the {serviceName} service. Once enabled, {transactionDurationGraphText} will show the expected bounds and annotate
the graph once the anomaly score is &gt;=75."
values={{
serviceName,
transactionDurationGraphText: (
<b>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText',
{
defaultMessage: 'the transaction duration graph'
}
)}
</b>
)
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription"
defaultMessage="Jobs can be created for each service + transaction type combination.
Once a job is created, you can manage it and see more details in the {mlJobsPageLink}."
values={{
mlJobsPageLink: (
<KibanaLink pathname={'/app/ml'}>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText',
{
defaultMessage:
'Machine Learning jobs management page'
}
)}
</KibanaLink>
)
}}
/>{' '}
<em>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText',
{
defaultMessage:
'Note: It might take a few minutes for the job to begin calculating results.'
}
)}
</em>
</p>
</EuiText>
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="flexEnd"
>
<EuiFlexItem>
{this.props.serviceTransactionTypes.length > 1 ? (
<TransactionSelect
serviceName={serviceName}
transactionTypes={this.props.serviceTransactionTypes}
selected={this.state.selectedTransactionType}
existingJobs={data.jobs}
onChange={value =>
this.setState({
selectedTransactionType: value
})
}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow>
<EuiButton
onClick={this.createJob}
fill
disabled={isLoading || hasMLJob || !hasIndexPattern}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel',
{
defaultMessage: 'Create new job'
}
)}
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}}
/>
);
}
}

View file

@ -0,0 +1,203 @@
/*
* 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 { Location } from 'history';
import React, { Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { MLJobLink } from 'x-pack/plugins/apm/public/components/shared/Links/MLJobLink';
import { startMLJob } from 'x-pack/plugins/apm/public/services/rest/ml';
import { getAPMIndexPattern } from 'x-pack/plugins/apm/public/services/rest/savedObjects';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { MachineLearningFlyoutView } from './view';
interface Props {
isOpen: boolean;
onClose: () => void;
urlParams: IUrlParams;
location: Location;
serviceTransactionTypes: string[];
}
interface State {
isCreatingJob: boolean;
hasMLJob: boolean;
hasIndexPattern: boolean;
selectedTransactionType?: string;
}
export class MachineLearningFlyout extends Component<Props, State> {
public state: State = {
isCreatingJob: false,
hasIndexPattern: false,
hasMLJob: false,
selectedTransactionType: this.props.urlParams.transactionType
};
public willUnmount = false;
public componentWillUnmount() {
this.willUnmount = true;
}
public async componentDidMount() {
const indexPattern = await getAPMIndexPattern();
if (!this.willUnmount) {
// TODO: this is causing warning from react because setState happens after
// the component has been unmounted - dispite of the checks
this.setState({ hasIndexPattern: !!indexPattern });
}
}
// TODO: This should use `getDerivedStateFromProps`
public componentDidUpdate(prevProps: Props) {
if (
prevProps.urlParams.transactionType !==
this.props.urlParams.transactionType
) {
this.setState({
selectedTransactionType: this.props.urlParams.transactionType
});
}
}
public onClickCreate = async () => {
this.setState({ isCreatingJob: true });
try {
const { serviceName, transactionType } = this.props.urlParams;
if (!serviceName || !transactionType) {
throw new Error(
'Service name and transaction type are required to create this ML job'
);
}
const res = await startMLJob({ serviceName, transactionType });
const didSucceed = res.datafeeds[0].success && res.jobs[0].success;
if (!didSucceed) {
throw new Error('Creating ML job failed');
}
this.addSuccessToast();
} catch (e) {
this.addErrorToast();
}
this.setState({ isCreatingJob: false });
this.props.onClose();
};
public addErrorToast = () => {
const { urlParams } = this.props;
const { serviceName } = urlParams;
if (!serviceName) {
return;
}
toastNotifications.addWarning({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle',
{
defaultMessage: 'Job creation failed'
}
),
text: (
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText',
{
defaultMessage:
'Your current license may not allow for creating machine learning jobs, or this job may already exist.'
}
)}
</p>
)
});
};
public addSuccessToast = () => {
const { location, urlParams } = this.props;
const { serviceName, transactionType } = urlParams;
if (!serviceName) {
return;
}
toastNotifications.addSuccess({
title: i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle',
{
defaultMessage: 'Job successfully created'
}
),
text: (
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText',
{
defaultMessage:
'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.',
values: {
serviceName,
transactionType
}
}
)}{' '}
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={location}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText',
{
defaultMessage: 'View job'
}
)}
</MLJobLink>
</p>
)
});
};
public onChangeTransaction(value: string) {
this.setState({
selectedTransactionType: value
});
}
public render() {
const {
isOpen,
onClose,
urlParams,
location,
serviceTransactionTypes
} = this.props;
const { serviceName, transactionType } = urlParams;
const {
isCreatingJob,
hasIndexPattern,
selectedTransactionType
} = this.state;
if (!isOpen || !serviceName) {
return null;
}
return (
<MachineLearningFlyoutView
hasIndexPattern={hasIndexPattern}
isCreatingJob={isCreatingJob}
location={location}
onChangeTransaction={this.onChangeTransaction}
onClickCreate={this.onClickCreate}
onClose={onClose}
selectedTransactionType={selectedTransactionType}
serviceName={serviceName}
serviceTransactionTypes={serviceTransactionTypes}
transactionType={transactionType}
/>
);
}
}

View file

@ -0,0 +1,252 @@
/*
* 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 {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
EuiSpacer,
EuiText,
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { Location } from 'history';
import React from 'react';
import { getMlJobId } from 'x-pack/plugins/apm/common/ml_job_constants';
import { KibanaLink } from 'x-pack/plugins/apm/public/components/shared/Links/KibanaLink';
import { MLJobLink } from 'x-pack/plugins/apm/public/components/shared/Links/MLJobLink';
import {
FETCH_STATUS,
useFetcher
} from 'x-pack/plugins/apm/public/hooks/useFetcher';
import { getMLJob } from 'x-pack/plugins/apm/public/services/rest/ml';
import { TransactionSelect } from './TransactionSelect';
interface Props {
hasIndexPattern: boolean;
isCreatingJob: boolean;
location: Location;
onChangeTransaction: (value: string) => void;
onClickCreate: () => void;
onClose: () => void;
selectedTransactionType?: string;
serviceName: string;
serviceTransactionTypes: string[];
transactionType?: string;
}
const INITIAL_DATA = { count: 0, jobs: [] };
export function MachineLearningFlyoutView({
hasIndexPattern,
isCreatingJob,
location,
onChangeTransaction,
onClickCreate,
onClose,
selectedTransactionType,
serviceName,
serviceTransactionTypes,
transactionType
}: Props) {
const { data = INITIAL_DATA, status } = useFetcher(
() => getMLJob({ serviceName, transactionType }),
[serviceName, transactionType]
);
if (status === FETCH_STATUS.LOADING) {
return null;
}
const hasMLJob = data.jobs.some(
job => job.job_id === getMlJobId(serviceName, selectedTransactionType)
);
return (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader>
<EuiTitle>
<h2>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle',
{
defaultMessage: 'Enable anomaly detection'
}
)}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
</EuiFlyoutHeader>
<EuiFlyoutBody>
{hasMLJob && (
<div>
<EuiCallOut
title={i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle',
{
defaultMessage: 'Job already exists'
}
)}
color="success"
iconType="check"
>
<p>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription',
{
defaultMessage:
'There is currently a job running for {serviceName} ({transactionType}).',
values: {
serviceName,
transactionType
}
}
)}{' '}
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={location}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText',
{
defaultMessage: 'View existing job'
}
)}
</MLJobLink>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</div>
)}
{!hasIndexPattern && (
<div>
<EuiCallOut
title={
<span>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.noPatternTitle"
defaultMessage="No APM index pattern available. To create a job, please import the APM index pattern via the {setupInstructionLink}"
values={{
setupInstructionLink: (
<KibanaLink
pathname={'/app/kibana'}
hash={`/home/tutorial/apm`}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.noPatternTitle.setupInstructionLinkText',
{
defaultMessage: 'Setup Instructions'
}
)}
</KibanaLink>
)
}}
/>
</span>
}
color="warning"
iconType="alert"
/>
<EuiSpacer size="m" />
</div>
)}
<EuiText>
<p>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription"
defaultMessage="Here you can create a machine learning job to calculate anomaly scores on durations for APM transactions
within the {serviceName} service. Once enabled, {transactionDurationGraphText} will show the expected bounds and annotate
the graph once the anomaly score is &gt;=75."
values={{
serviceName,
transactionDurationGraphText: (
<b>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText',
{
defaultMessage: 'the transaction duration graph'
}
)}
</b>
)
}}
/>
</p>
<p>
<FormattedMessage
id="xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription"
defaultMessage="Jobs can be created for each service + transaction type combination.
Once a job is created, you can manage it and see more details in the {mlJobsPageLink}."
values={{
mlJobsPageLink: (
<KibanaLink pathname={'/app/ml'}>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText',
{
defaultMessage: 'Machine Learning jobs management page'
}
)}
</KibanaLink>
)
}}
/>{' '}
<em>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.noteText',
{
defaultMessage:
'Note: It might take a few minutes for the job to begin calculating results.'
}
)}
</em>
</p>
</EuiText>
<EuiSpacer />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
<EuiFlexItem>
{serviceTransactionTypes.length > 1 ? (
<TransactionSelect
serviceName={serviceName}
transactionTypes={serviceTransactionTypes}
selected={selectedTransactionType}
existingJobs={data.jobs}
onChange={onChangeTransaction}
/>
) : null}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow>
<EuiButton
onClick={onClickCreate}
fill
disabled={isCreatingJob || hasMLJob || !hasIndexPattern}
>
{i18n.translate(
'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel',
{
defaultMessage: 'Create new job'
}
)}
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -4,16 +4,181 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { IReduxState } from 'x-pack/plugins/apm/public/store/rootReducer';
import { selectIsMLAvailable } from 'x-pack/plugins/apm/public/store/selectors/license';
import { ServiceIntegrationsView } from './view';
import {
EuiButton,
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
EuiPopover
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { memoize } from 'lodash';
import React, { Fragment } from 'react';
import chrome from 'ui/chrome';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { LicenseContext } from '../../Main/LicenseCheck';
import { MachineLearningFlyout } from './MachineLearningFlyout';
import { WatcherFlyout } from './WatcherFlyout';
function mapStateToProps(state = {} as IReduxState) {
return {
mlAvailable: selectIsMLAvailable(state)
};
interface Props {
location: Location;
transactionTypes: string[];
urlParams: IUrlParams;
}
interface State {
isPopoverOpen: boolean;
activeFlyout: FlyoutName;
}
type FlyoutName = null | 'ML' | 'Watcher';
const ServiceIntegrations = connect(mapStateToProps)(ServiceIntegrationsView);
export { ServiceIntegrations };
export class ServiceIntegrations extends React.Component<Props, State> {
public state: State = { isPopoverOpen: false, activeFlyout: null };
public getPanelItems = memoize((mlAvailable: boolean) => {
let panelItems: EuiContextMenuPanelItemDescriptor[] = [];
if (mlAvailable) {
panelItems = panelItems.concat(this.getMLPanelItems());
}
return panelItems.concat(this.getWatcherPanelItems());
});
public getMLPanelItems = () => {
return [
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel',
{
defaultMessage: 'Enable ML anomaly detection'
}
),
icon: 'machineLearningApp',
toolTipContent: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip',
{
defaultMessage: 'Set up a machine learning job for this service'
}
),
onClick: () => {
this.closePopover();
this.openFlyout('ML');
}
},
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.viewMLJobsButtonLabel',
{
defaultMessage: 'View existing ML jobs'
}
),
icon: 'machineLearningApp',
href: chrome.addBasePath('/app/ml'),
target: '_blank',
onClick: () => this.closePopover()
}
];
};
public getWatcherPanelItems = () => {
return [
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel',
{
defaultMessage: 'Enable watcher error reports'
}
),
icon: 'watchesApp',
onClick: () => {
this.closePopover();
this.openFlyout('Watcher');
}
},
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel',
{
defaultMessage: 'View existing watches'
}
),
icon: 'watchesApp',
href: chrome.addBasePath(
'/app/kibana#/management/elasticsearch/watcher'
),
target: '_blank',
onClick: () => this.closePopover()
}
];
};
public openPopover = () =>
this.setState({
isPopoverOpen: true
});
public closePopover = () =>
this.setState({
isPopoverOpen: false
});
public openFlyout = (name: FlyoutName) =>
this.setState({ activeFlyout: name });
public closeFlyouts = () => this.setState({ activeFlyout: null });
public render() {
const button = (
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={this.openPopover}
>
{i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel',
{
defaultMessage: 'Integrations'
}
)}
</EuiButton>
);
return (
<LicenseContext.Consumer>
{license => (
<Fragment>
<EuiPopover
id="integrations-menu"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
items: this.getPanelItems(license.features.ml.is_available)
}
]}
/>
</EuiPopover>
<MachineLearningFlyout
location={this.props.location}
isOpen={this.state.activeFlyout === 'ML'}
onClose={this.closeFlyouts}
urlParams={this.props.urlParams}
serviceTransactionTypes={this.props.transactionTypes}
/>
<WatcherFlyout
location={this.props.location}
isOpen={this.state.activeFlyout === 'Watcher'}
onClose={this.closeFlyouts}
urlParams={this.props.urlParams}
/>
</Fragment>
)}
</LicenseContext.Consumer>
);
}
}

View file

@ -1,183 +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 {
EuiButton,
EuiContextMenu,
EuiContextMenuPanelItemDescriptor,
EuiPopover
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { memoize } from 'lodash';
import React from 'react';
import chrome from 'ui/chrome';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { MachineLearningFlyout } from './MachineLearningFlyout';
import { WatcherFlyout } from './WatcherFlyout';
export interface ServiceIntegrationProps {
mlAvailable: boolean;
location: Location;
transactionTypes: string[];
urlParams: IUrlParams;
}
interface ServiceIntegrationState {
isPopoverOpen: boolean;
activeFlyout: FlyoutName;
}
type FlyoutName = null | 'ML' | 'Watcher';
export class ServiceIntegrationsView extends React.Component<
ServiceIntegrationProps,
ServiceIntegrationState
> {
public state = { isPopoverOpen: false, activeFlyout: null };
public getPanelItems = memoize((mlAvailable: boolean) => {
let panelItems: EuiContextMenuPanelItemDescriptor[] = [];
if (mlAvailable) {
panelItems = panelItems.concat(this.getMLPanelItems());
}
return panelItems.concat(this.getWatcherPanelItems());
});
public getMLPanelItems = () => {
return [
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonLabel',
{
defaultMessage: 'Enable ML anomaly detection'
}
),
icon: 'machineLearningApp',
toolTipContent: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableMLAnomalyDetectionButtonTooltip',
{
defaultMessage: 'Set up a machine learning job for this service'
}
),
onClick: () => {
this.closePopover();
this.openFlyout('ML');
}
},
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.viewMLJobsButtonLabel',
{
defaultMessage: 'View existing ML jobs'
}
),
icon: 'machineLearningApp',
href: chrome.addBasePath('/app/ml'),
target: '_blank',
onClick: () => this.closePopover()
}
];
};
public getWatcherPanelItems = () => {
return [
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.enableWatcherErrorReportsButtonLabel',
{
defaultMessage: 'Enable watcher error reports'
}
),
icon: 'watchesApp',
onClick: () => {
this.closePopover();
this.openFlyout('Watcher');
}
},
{
name: i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.viewWatchesButtonLabel',
{
defaultMessage: 'View existing watches'
}
),
icon: 'watchesApp',
href: chrome.addBasePath(
'/app/kibana#/management/elasticsearch/watcher'
),
target: '_blank',
onClick: () => this.closePopover()
}
];
};
public openPopover = () =>
this.setState({
isPopoverOpen: true
});
public closePopover = () =>
this.setState({
isPopoverOpen: false
});
public openFlyout = (name: FlyoutName) =>
this.setState({ activeFlyout: name });
public closeFlyouts = () => this.setState({ activeFlyout: null });
public render() {
const button = (
<EuiButton
iconType="arrowDown"
iconSide="right"
onClick={this.openPopover}
>
{i18n.translate(
'xpack.apm.serviceDetails.integrationsMenu.integrationsButtonLabel',
{
defaultMessage: 'Integrations'
}
)}
</EuiButton>
);
return (
<React.Fragment>
<EuiPopover
id="integrations-menu"
button={button}
isOpen={this.state.isPopoverOpen}
closePopover={this.closePopover}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
items: this.getPanelItems(this.props.mlAvailable)
}
]}
/>
</EuiPopover>
<MachineLearningFlyout
location={this.props.location}
isOpen={this.state.activeFlyout === 'ML'}
onClose={this.closeFlyouts}
urlParams={this.props.urlParams}
serviceTransactionTypes={this.props.transactionTypes}
/>
<WatcherFlyout
location={this.props.location}
isOpen={this.state.activeFlyout === 'Watcher'}
onClose={this.closeFlyouts}
urlParams={this.props.urlParams}
/>
</React.Fragment>
);
}
}

View file

@ -17,10 +17,11 @@ import React from 'react';
import { ErrorDistribution } from 'x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution';
import { SyncChartGroup } from 'x-pack/plugins/apm/public/components/shared/charts/SyncChartGroup';
import { TransactionCharts } from 'x-pack/plugins/apm/public/components/shared/charts/TransactionCharts';
import { ErrorDistributionRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/errorDistribution';
import { MetricsChartDataRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/serviceMetricsCharts';
import { TransactionOverviewChartsRequestForAllTypes } from 'x-pack/plugins/apm/public/store/reactReduxRequest/transactionOverviewCharts';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { useFetcher } from '../../../hooks/useFetcher';
import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts';
import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts';
import { loadErrorDistribution } from '../../../services/rest/apm/error_groups';
import { CPUUsageChart } from './CPUUsageChart';
import { MemoryUsageChart } from './MemoryUsageChart';
@ -30,17 +31,29 @@ interface ServiceMetricsProps {
}
export function ServiceMetrics({ urlParams, location }: ServiceMetricsProps) {
const { serviceName, start, end, errorGroupId, kuery } = urlParams;
const { data: errorDistributionData } = useFetcher(
() =>
loadErrorDistribution({ serviceName, start, end, errorGroupId, kuery }),
[serviceName, start, end, errorGroupId, kuery]
);
const { data: transactionOverviewChartsData } = useTransactionOverviewCharts(
urlParams
);
const { data: serviceMetricChartData } = useServiceMetricCharts(urlParams);
if (!errorDistributionData) {
return null;
}
return (
<React.Fragment>
<TransactionOverviewChartsRequestForAllTypes
<TransactionCharts
charts={transactionOverviewChartsData}
urlParams={urlParams}
render={({ data }) => (
<TransactionCharts
charts={data}
urlParams={urlParams}
location={location}
/>
)}
location={location}
/>
<EuiSpacer size="l" />
@ -48,18 +61,13 @@ export function ServiceMetrics({ urlParams, location }: ServiceMetricsProps) {
<EuiFlexGroup>
<EuiFlexItem>
<EuiPanel>
<ErrorDistributionRequest
urlParams={urlParams}
render={({ data }) => (
<ErrorDistribution
distribution={data}
title={i18n.translate(
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
{
defaultMessage: 'Error occurrences'
}
)}
/>
<ErrorDistribution
distribution={errorDistributionData}
title={i18n.translate(
'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle',
{
defaultMessage: 'Error occurrences'
}
)}
/>
</EuiPanel>
@ -68,34 +76,27 @@ export function ServiceMetrics({ urlParams, location }: ServiceMetricsProps) {
<EuiSpacer size="l" />
<MetricsChartDataRequest
urlParams={urlParams}
render={({ data }) => {
return (
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiPanel>
<CPUUsageChart
data={data.cpu}
hoverXHandlers={hoverXHandlers}
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel>
<MemoryUsageChart
data={data.memory}
hoverXHandlers={hoverXHandlers}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
)}
/>
);
}}
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiPanel>
<CPUUsageChart
data={serviceMetricChartData.cpu}
hoverXHandlers={hoverXHandlers}
/>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel>
<MemoryUsageChart
data={serviceMetricChartData.memory}
hoverXHandlers={hoverXHandlers}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
)}
/>
<EuiSpacer size="xxl" />

View file

@ -6,56 +6,57 @@
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Location } from 'history';
import React, { Fragment } from 'react';
import { ServiceDetailsRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/serviceDetails';
import React from 'react';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceDetails } from '../../../services/rest/apm/services';
// @ts-ignore
import { FilterBar } from '../../shared/FilterBar';
import { ServiceDetailTabs } from './ServiceDetailTabs';
import { ServiceIntegrations } from './ServiceIntegrations';
interface ServiceDetailsProps {
interface Props {
urlParams: IUrlParams;
location: Location;
}
export class ServiceDetailsView extends React.Component<ServiceDetailsProps> {
public render() {
const { urlParams, location } = this.props;
return (
<ServiceDetailsRequest
urlParams={urlParams}
render={({ data }) => {
return (
<Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="l">
<h1>{urlParams.serviceName}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIntegrations
transactionTypes={data.types}
location={this.props.location}
urlParams={urlParams}
/>
</EuiFlexItem>
</EuiFlexGroup>
export function ServiceDetailsView({ urlParams, location }: Props) {
const { serviceName, start, end, kuery } = urlParams;
const { data: serviceDetailsData } = useFetcher(
() => loadServiceDetails({ serviceName, start, end, kuery }),
[serviceName, start, end, kuery]
);
<EuiSpacer />
<FilterBar />
<ServiceDetailTabs
location={location}
urlParams={urlParams}
transactionTypes={data.types}
/>
</Fragment>
);
}}
/>
);
if (!serviceDetailsData) {
return null;
}
return (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="l">
<h1>{urlParams.serviceName}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIntegrations
transactionTypes={serviceDetailsData.types}
location={location}
urlParams={urlParams}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<FilterBar />
<ServiceDetailTabs
location={location}
urlParams={urlParams}
transactionTypes={serviceDetailsData.types}
/>
</React.Fragment>
);
}

View file

@ -16,7 +16,7 @@ import { asDecimal, asMillis } from '../../../../utils/formatters';
import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable';
interface Props {
items: IServiceListItem[];
items?: IServiceListItem[];
noItemsMessage?: React.ReactNode;
}

View file

@ -1,77 +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 React from 'react';
import { shallow } from 'enzyme';
import { ServiceOverview } from '../view';
import { STATUS } from '../../../../constants';
import * as apmRestServices from '../../../../services/rest/apm/status_check';
jest.mock('../../../../services/rest/apm/status_check');
describe('Service Overview -> View', () => {
let mockAgentStatus;
let wrapper;
let instance;
beforeEach(() => {
mockAgentStatus = {
dataFound: true
};
apmRestServices.loadAgentStatus = jest.fn(() =>
Promise.resolve(mockAgentStatus)
);
wrapper = shallow(<ServiceOverview serviceList={{ data: [] }} />);
instance = wrapper.instance();
});
it('should render when historical data is found', () => {
expect(wrapper).toMatchSnapshot();
const List = wrapper
.find('ServiceListRequest')
.props()
.render({});
expect(List.props).toMatchSnapshot();
});
it('should render when historical data is not found', () => {
wrapper.setState({ historicalDataFound: false });
expect(wrapper).toMatchSnapshot();
const List = wrapper
.find('ServiceListRequest')
.props()
.render({});
expect(List.props).toMatchSnapshot();
});
it('should check for historical data once', () => {});
describe('checking for historical data', () => {
it('should set historical data to true if data is found', async () => {
const props = {
serviceList: {
status: STATUS.SUCCESS,
data: []
}
};
await instance.checkForHistoricalData(props);
expect(wrapper.state('historicalDataFound')).toEqual(true);
});
it('should set historical data state to false if data is NOT found', async () => {
const props = {
serviceList: {
status: STATUS.SUCCESS,
data: []
}
};
mockAgentStatus.dataFound = false;
await instance.checkForHistoricalData(props);
expect(wrapper.state('historicalDataFound')).toEqual(false);
});
});
});

View file

@ -0,0 +1,132 @@
/*
* 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 { Provider } from 'react-redux';
import { render, wait, waitForElement } from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import * as apmRestServices from 'x-pack/plugins/apm/public/services/rest/apm/services';
// @ts-ignore
import configureStore from 'x-pack/plugins/apm/public/store/config/configureStore';
import * as statusCheck from '../../../../services/rest/apm/status_check';
import { ServiceOverview } from '../view';
function Comp() {
const store = configureStore();
return (
<Provider store={store}>
<ServiceOverview urlParams={{}} />
</Provider>
);
}
describe('Service Overview -> View', () => {
afterEach(() => {
jest.resetAllMocks();
});
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* tslint:disable:no-console */
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
it('should render services, when list is not empty', async () => {
// mock rest requests
const spy1 = jest
.spyOn(statusCheck, 'loadAgentStatus')
.mockResolvedValue(true);
const spy2 = jest
.spyOn(apmRestServices, 'loadServiceList')
.mockResolvedValue([
{
serviceName: 'My Python Service',
agentName: 'python',
transactionsPerMinute: 100,
errorsPerMinute: 200,
avgResponseTime: 300
},
{
serviceName: 'My Go Service',
agentName: 'go',
transactionsPerMinute: 400,
errorsPerMinute: 500,
avgResponseTime: 600
}
]);
const { container, getByText } = render(<Comp />);
// wait for requests to be made
await wait(
() =>
expect(spy1).toHaveBeenCalledTimes(1) &&
expect(spy2).toHaveBeenCalledTimes(1)
);
await waitForElement(() => getByText('My Python Service'));
expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot();
});
it('should render getting started message, when list is empty and no historical data is found', async () => {
// mock rest requests
const spy1 = jest
.spyOn(statusCheck, 'loadAgentStatus')
.mockResolvedValue(false);
const spy2 = jest
.spyOn(apmRestServices, 'loadServiceList')
.mockResolvedValue([]);
const { container, getByText } = render(<Comp />);
// wait for requests to be made
await wait(
() =>
expect(spy1).toHaveBeenCalledTimes(1) &&
expect(spy2).toHaveBeenCalledTimes(1)
);
// wait for elements to be rendered
await waitForElement(() =>
getByText(
"Looks like you don't have any APM services installed. Let's add some!"
)
);
expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot();
});
it('should render empty message, when list is empty and historical data is found', async () => {
// mock rest requests
const spy1 = jest
.spyOn(statusCheck, 'loadAgentStatus')
.mockResolvedValue(true);
const spy2 = jest
.spyOn(apmRestServices, 'loadServiceList')
.mockResolvedValue([]);
const { container, getByText } = render(<Comp />);
// wait for requests to be made
await wait(
() =>
expect(spy1).toHaveBeenCalledTimes(1) &&
expect(spy2).toHaveBeenCalledTimes(1)
);
// wait for elements to be rendered
await waitForElement(() => getByText('No services found'));
expect(container.querySelectorAll('.euiTableRow')).toMatchSnapshot();
});
});

View file

@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Service Overview -> View should render when historical data is found 1`] = `
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<ServiceListRequest
render={[Function]}
/>
</EuiPanel>
`;
exports[`Service Overview -> View should render when historical data is found 2`] = `
Object {
"items": Array [],
"noItemsMessage": <NoServicesMessage
historicalDataFound={true}
/>,
}
`;
exports[`Service Overview -> View should render when historical data is not found 1`] = `
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<ServiceListRequest
render={[Function]}
/>
</EuiPanel>
`;
exports[`Service Overview -> View should render when historical data is not found 2`] = `
Object {
"items": Array [],
"noItemsMessage": <NoServicesMessage
historicalDataFound={false}
/>,
}
`;

View file

@ -0,0 +1,296 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Service Overview -> View should render empty message, when list is empty and historical data is found 1`] = `
NodeList [
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colspan="5"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
>
<span
class="euiTableCellContent__text"
>
<div
class="euiEmptyPrompt"
>
<span
class="euiTextColor euiTextColor--subdued"
>
<div
class="euiTitle euiTitle--small"
>
No services found
</div>
<div
class="euiSpacer euiSpacer--m"
/>
</span>
</div>
</span>
</div>
</td>
</tr>,
]
`;
exports[`Service Overview -> View should render getting started message, when list is empty and no historical data is found 1`] = `
NodeList [
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colspan="5"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
>
<span
class="euiTableCellContent__text"
>
<div
class="euiEmptyPrompt"
>
<span
class="euiTextColor euiTextColor--subdued"
>
<div
class="euiTitle euiTitle--small"
>
Looks like you don't have any APM services installed. Let's add some!
</div>
<div
class="euiSpacer euiSpacer--m"
/>
<div
class="euiText euiText--medium"
>
<p>
Upgrading from a pre-7.x version? Make sure you've also upgraded
your APM server instance(s) to at least 7.0.
</p>
<p>
You may also have old data that needs to be migrated.
<a
class="euiLink euiLink--primary"
href="/app/kibana#/management/elasticsearch/upgrade_assistant?"
>
Learn more by visiting the Kibana Upgrade Assistant
</a>
.
</p>
</div>
</span>
<div
class="euiSpacer euiSpacer--l"
/>
<div
class="euiSpacer euiSpacer--s"
/>
<a
class="euiLink euiLink--primary"
href="/app/kibana#/home/tutorial/apm?"
>
<button
class="euiButton euiButton--primary euiButton--small euiButton--fill"
type="button"
>
<span
class="euiButton__content"
>
<span
class="euiButton__text"
>
Setup Instructions
</span>
</span>
</button>
</a>
</div>
</span>
</div>
</td>
</tr>,
]
`;
exports[`Service Overview -> View should render services, when list is not empty 1`] = `
NodeList [
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
width="50%"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Name
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<span
class="euiToolTipAnchor"
>
<a
aria-describedby="service-name-tooltip"
class="euiLink euiLink--primary sc-bdVaJa eQDnXY"
href="#/My Go Service/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0"
>
My Go Service
</a>
</span>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Agent
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
go
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Avg. response time
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
1 ms
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Trans. per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
400.0 tpm
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Errors per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
500.0 err.
</div>
</td>
</tr>,
<tr
class="euiTableRow"
>
<td
class="euiTableRowCell"
width="50%"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Name
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
<span
class="euiToolTipAnchor"
>
<a
aria-describedby="service-name-tooltip"
class="euiLink euiLink--primary sc-bdVaJa eQDnXY"
href="#/My Python Service/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0"
>
My Python Service
</a>
</span>
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Agent
</div>
<div
class="euiTableCellContent euiTableCellContent--overflowingContent"
>
python
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Avg. response time
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
0 ms
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Trans. per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
100.0 tpm
</div>
</td>
<td
class="euiTableRowCell"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Errors per minute
</div>
<div
class="euiTableCellContent euiTableCellContent--alignRight euiTableCellContent--overflowingContent"
>
200.0 err.
</div>
</td>
</tr>,
]
`;

View file

@ -5,14 +5,12 @@
*/
import { connect } from 'react-redux';
import { getServiceList } from 'x-pack/plugins/apm/public/store/reactReduxRequest/serviceList';
import { IReduxState } from 'x-pack/plugins/apm/public/store/rootReducer';
import { getUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { ServiceOverview as View } from './view';
function mapStateToProps(state = {} as IReduxState) {
return {
serviceList: getServiceList(state),
urlParams: getUrlParams(state)
};
}

View file

@ -5,59 +5,32 @@
*/
import { EuiPanel } from '@elastic/eui';
import React, { Component } from 'react';
import { RRRRenderResponse } from 'react-redux-request';
import React from 'react';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { IServiceListItem } from 'x-pack/plugins/apm/server/lib/services/get_services';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceList } from '../../../services/rest/apm/services';
import { loadAgentStatus } from '../../../services/rest/apm/status_check';
import { ServiceListRequest } from '../../../store/reactReduxRequest/serviceList';
import { NoServicesMessage } from './NoServicesMessage';
import { ServiceList } from './ServiceList';
interface Props {
urlParams: IUrlParams;
serviceList: RRRRenderResponse<IServiceListItem[]>;
}
interface State {
// any data submitted from APM agents found (not just in the given time range)
historicalDataFound: boolean;
}
export class ServiceOverview extends Component<Props, State> {
public state = { historicalDataFound: true };
public async checkForHistoricalData() {
const result = await loadAgentStatus();
this.setState({ historicalDataFound: result.dataFound });
}
public componentDidMount() {
this.checkForHistoricalData();
}
public render() {
const { urlParams } = this.props;
// Render method here uses this.props.serviceList instead of received "data" from RRR
// to make it easier to test -- mapStateToProps uses the RRR selector so the data
// is the same either way
return (
<EuiPanel>
<ServiceListRequest
urlParams={urlParams}
render={() => (
<ServiceList
items={this.props.serviceList.data}
noItemsMessage={
<NoServicesMessage
historicalDataFound={this.state.historicalDataFound}
/>
}
/>
)}
/>
</EuiPanel>
);
}
export function ServiceOverview({ urlParams }: Props) {
const { start, end, kuery } = urlParams;
const { data: agentStatus = true } = useFetcher(() => loadAgentStatus(), []);
const { data: serviceListData } = useFetcher(
() => loadServiceList({ start, end, kuery }),
[start, end, kuery]
);
return (
<EuiPanel>
<ServiceList
items={serviceListData}
noItemsMessage={<NoServicesMessage historicalDataFound={agentStatus} />}
/>
</EuiPanel>
);
}

View file

@ -11,6 +11,7 @@ import styled from 'styled-components';
import { ITransactionGroup } from 'x-pack/plugins/apm/server/lib/transaction_groups/transform';
import { fontSizes, truncate } from '../../../style/variables';
import { asMillis } from '../../../utils/formatters';
import { EmptyMessage } from '../../shared/EmptyMessage';
import { ImpactBar } from '../../shared/ImpactBar';
import { TransactionLink } from '../../shared/Links/TransactionLink';
import { ITableColumn, ManagedTable } from '../../shared/ManagedTable';
@ -22,7 +23,6 @@ const StyledTransactionLink = styled(TransactionLink)`
interface Props {
items: ITransactionGroup[];
noItemsMessage: React.ReactNode;
isLoading: boolean;
}
@ -88,7 +88,15 @@ const traceListColumns: Array<ITableColumn<ITransactionGroup>> = [
}
];
export function TraceList({ items = [], noItemsMessage, isLoading }: Props) {
const noItemsMessage = (
<EmptyMessage
heading={i18n.translate('xpack.apm.tracesTable.notFoundLabel', {
defaultMessage: 'No traces found for this query'
})}
/>
);
export function TraceList({ items = [], isLoading }: Props) {
const noItems = isLoading ? null : noItemsMessage;
return (
<ManagedTable

View file

@ -5,39 +5,26 @@
*/
import { EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { RRRRenderResponse } from 'react-redux-request';
import { TraceListAPIResponse } from 'x-pack/plugins/apm/server/lib/traces/get_top_traces';
import { TraceListRequest } from '../../../store/reactReduxRequest/traceList';
import { EmptyMessage } from '../../shared/EmptyMessage';
import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher';
import { loadTraceList } from '../../../services/rest/apm/traces';
import { IUrlParams } from '../../../store/urlParams';
import { TraceList } from './TraceList';
interface Props {
urlParams: object;
urlParams: IUrlParams;
}
export function TraceOverview(props: Props) {
const { urlParams } = props;
const { start, end, kuery } = props.urlParams;
const { status, data = [] } = useFetcher(
() => loadTraceList({ start, end, kuery }),
[start, end, kuery]
);
return (
<EuiPanel>
<TraceListRequest
urlParams={urlParams}
render={({ data, status }: RRRRenderResponse<TraceListAPIResponse>) => (
<TraceList
items={data}
isLoading={status === 'LOADING'}
noItemsMessage={
<EmptyMessage
heading={i18n.translate('xpack.apm.tracesTable.notFoundLabel', {
defaultMessage: 'No traces found for this query'
})}
/>
}
/>
)}
/>
<TraceList items={data} isLoading={status === FETCH_STATUS.LOADING} />
</EuiPanel>
);
}

View file

@ -52,7 +52,7 @@ export function getFormattedBuckets(buckets: IBucket[], bucketSize: number) {
interface Props {
location: Location;
distribution: ITransactionDistributionAPIResponse;
distribution?: ITransactionDistributionAPIResponse;
urlParams: IUrlParams;
}
@ -95,9 +95,45 @@ export class TransactionDistribution extends Component<Props> {
);
};
public redirectToTransactionType() {
const { urlParams, location, distribution } = this.props;
if (
!distribution ||
!distribution.defaultSample ||
urlParams.traceId ||
urlParams.transactionId
) {
return;
}
const { traceId, transactionId } = distribution.defaultSample;
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
traceId,
transactionId
})
});
}
public componentDidMount() {
this.redirectToTransactionType();
}
public componentDidUpdate() {
this.redirectToTransactionType();
}
public render() {
const { location, distribution, urlParams } = this.props;
if (!distribution || !urlParams.traceId || !urlParams.transactionId) {
return null;
}
const buckets = getFormattedBuckets(
distribution.buckets,
distribution.bucketSize
@ -163,7 +199,7 @@ export class TransactionDistribution extends Component<Props> {
bucketIndex={bucketIndex}
onClick={(bucket: IChartPoint) => {
if (bucket.sample && bucket.y > 0) {
history.replace({
history.push({
...location,
search: fromQuery({
...toQuery(location.search),

View file

@ -96,13 +96,12 @@ describe('waterfall_helpers', () => {
it('should return full waterfall', () => {
const entryTransactionId = 'myTransactionId1';
const errorCountsByTransactionId = {
const errorsPerTransaction = {
myTransactionId1: 2,
myTransactionId2: 3
};
const waterfall = getWaterfall(
hits,
errorCountsByTransactionId,
{ trace: hits, errorsPerTransaction },
entryTransactionId
);
expect(waterfall.orderedItems.length).toBe(6);
@ -112,13 +111,12 @@ describe('waterfall_helpers', () => {
it('should return partial waterfall', () => {
const entryTransactionId = 'myTransactionId2';
const errorCountsByTransactionId = {
const errorsPerTransaction = {
myTransactionId1: 2,
myTransactionId2: 3
};
const waterfall = getWaterfall(
hits,
errorCountsByTransactionId,
{ trace: hits, errorsPerTransaction },
entryTransactionId
);
expect(waterfall.orderedItems.length).toBe(4);
@ -128,13 +126,12 @@ describe('waterfall_helpers', () => {
it('getTransactionById', () => {
const entryTransactionId = 'myTransactionId1';
const errorCountsByTransactionId = {
const errorsPerTransaction = {
myTransactionId1: 2,
myTransactionId2: 3
};
const waterfall = getWaterfall(
hits,
errorCountsByTransactionId,
{ trace: hits, errorsPerTransaction },
entryTransactionId
);
const transaction = waterfall.getTransactionById('myTransactionId2');

View file

@ -239,8 +239,7 @@ function createGetTransactionById(itemsById: IWaterfallIndex) {
}
export function getWaterfall(
trace: TraceAPIResponse['trace'],
errorsPerTransaction: TraceAPIResponse['errorsPerTransaction'],
{ trace, errorsPerTransaction }: TraceAPIResponse,
entryTransactionId?: Transaction['transaction']['id']
): IWaterfall {
if (isEmpty(trace) || !entryTransactionId) {

View file

@ -7,10 +7,11 @@
import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import _ from 'lodash';
import React from 'react';
import { TransactionDetailsChartsRequest } from '../../../store/reactReduxRequest/transactionDetailsCharts';
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
import { WaterfallRequest } from '../../../store/reactReduxRequest/waterfall';
import { useTransactionDetailsCharts } from '../../../hooks/useTransactionDetailsCharts';
import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution';
import { useWaterfall } from '../../../hooks/useWaterfall';
import { IUrlParams } from '../../../store/urlParams';
import { TransactionCharts } from '../../shared/charts/TransactionCharts';
import { EmptyMessage } from '../../shared/EmptyMessage';
@ -19,12 +20,18 @@ import { TransactionDistribution } from './Distribution';
import { Transaction } from './Transaction';
interface Props {
mlAvailable: boolean;
urlParams: IUrlParams;
location: Location;
}
export function TransactionDetailsView({ urlParams, location }: Props) {
const { data: distributionData } = useTransactionDistribution(urlParams);
const { data: transactionDetailsChartsData } = useTransactionDetailsCharts(
urlParams
);
const { data: waterfall } = useWaterfall(urlParams);
const transaction = waterfall.getTransactionById(urlParams.transactionId);
return (
<div>
<EuiTitle size="l">
@ -32,75 +39,51 @@ export function TransactionDetailsView({ urlParams, location }: Props) {
</EuiTitle>
<EuiSpacer />
<FilterBar />
<EuiSpacer size="s" />
<TransactionDetailsChartsRequest
<TransactionCharts
charts={transactionDetailsChartsData}
urlParams={urlParams}
render={({ data }) => (
<TransactionCharts
charts={data}
urlParams={urlParams}
location={location}
/>
)}
location={location}
/>
<EuiSpacer />
<EuiPanel>
<TransactionDistributionRequest
<TransactionDistribution
distribution={distributionData}
urlParams={urlParams}
render={({ data }) => (
<TransactionDistribution
distribution={data}
urlParams={urlParams}
location={location}
/>
)}
location={location}
/>
</EuiPanel>
<EuiSpacer size="l" />
<WaterfallRequest
urlParams={urlParams}
traceId={urlParams.traceId}
render={({ data: waterfall }) => {
const transaction = waterfall.getTransactionById(
urlParams.transactionId
);
if (!transaction) {
return (
<EmptyMessage
heading={i18n.translate(
'xpack.apm.transactionDetails.noTransactionTitle',
{
defaultMessage: 'No transaction sample available.'
}
)}
subheading={i18n.translate(
'xpack.apm.transactionDetails.noTransactionDescription',
{
defaultMessage:
'Try another time range, reset the search filter or select another bucket from the distribution histogram.'
}
)}
/>
);
}
return (
<Transaction
location={location}
transaction={transaction}
urlParams={urlParams}
waterfall={waterfall}
/>
);
}}
/>
{!transaction ? (
<EmptyMessage
heading={i18n.translate(
'xpack.apm.transactionDetails.noTransactionTitle',
{
defaultMessage: 'No transaction sample available.'
}
)}
subheading={i18n.translate(
'xpack.apm.transactionDetails.noTransactionDescription',
{
defaultMessage:
'Try another time range, reset the search filter or select another bucket from the distribution histogram.'
}
)}
/>
) : (
<Transaction
location={location}
transaction={transaction}
urlParams={urlParams}
waterfall={waterfall}
/>
)}
</div>
);
}

View file

@ -1,74 +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 React from 'react';
import { shallow } from 'enzyme';
import { TransactionOverviewView } from '..';
jest.mock(
'ui/chrome',
() => ({
getBasePath: () => `/some/base/path`,
getInjected: key => {
if (key === 'mlEnabled') {
return true;
}
throw new Error(`inexpected key ${key}`);
},
getUiSettingsClient: () => {
return {
get: key => {
switch (key) {
case 'timepicker:timeDefaults':
return { from: 'now-15m', to: 'now', mode: 'quick' };
case 'timepicker:refreshIntervalDefaults':
return { display: 'Off', pause: false, value: 0 };
default:
throw new Error(`Unexpected config key: ${key}`);
}
}
};
}
}),
{ virtual: true }
);
const setup = () => {
const props = {
agentName: 'test-agent',
serviceName: 'test-service',
serviceTransactionTypes: ['a', 'b'],
location: {},
history: {
push: jest.fn()
},
urlParams: { transactionType: 'test-type', serviceName: 'MyServiceName' }
};
const wrapper = shallow(<TransactionOverviewView {...props} />);
return { props, wrapper };
};
describe('TransactionOverviewView', () => {
it('should render null if there is no transaction type in the search string', () => {
const { wrapper } = setup();
wrapper.setProps({ urlParams: { serviceName: 'MyServiceName' } });
expect(wrapper).toMatchInlineSnapshot(`""`);
});
it('should render with type filter controls', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render without type filter controls if there is just a single type', () => {
const { wrapper } = setup();
wrapper.setProps({
serviceTransactionTypes: ['a']
});
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import createHistory from 'history/createHashHistory';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { queryByLabelText, render } from 'react-testing-library';
// @ts-ignore
import configureStore from 'x-pack/plugins/apm/public/store/config/configureStore';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { TransactionOverview } from '..';
function setup(props: {
urlParams: IUrlParams;
serviceTransactionTypes: string[];
}) {
const store = configureStore();
const history = createHistory();
history.replace = jest.fn();
const { container } = render(
<Provider store={store}>
<Router history={history}>
<TransactionOverview {...props} />
</Router>
</Provider>
);
return { container, history };
}
describe('TransactionOverviewView', () => {
describe('when no transaction type is given', () => {
it('should render null', () => {
const { container } = setup({
serviceTransactionTypes: ['firstType', 'secondType'],
urlParams: {
serviceName: 'MyServiceName'
}
});
expect(container).toMatchInlineSnapshot(`<div />`);
});
it('should redirect to first type', () => {
const { history } = setup({
serviceTransactionTypes: ['firstType', 'secondType'],
urlParams: {
serviceName: 'MyServiceName'
}
});
expect(history.replace).toHaveBeenCalledWith(
expect.objectContaining({
pathname: '/MyServiceName/transactions/firstType'
})
);
});
});
const FILTER_BY_TYPE_LABEL = 'Filter by type';
describe('when transactionType is selected and multiple transaction types are given', () => {
it('should render dropdown with transaction types', () => {
const { container } = setup({
serviceTransactionTypes: ['firstType', 'secondType'],
urlParams: {
transactionType: 'secondType',
serviceName: 'MyServiceName'
}
});
expect(
queryByLabelText(container, FILTER_BY_TYPE_LABEL)
).toMatchSnapshot();
});
});
describe('when a transaction type is selected, and there are no other transaction types', () => {
it('should not render a dropdown with transaction types', () => {
const { container } = setup({
serviceTransactionTypes: ['firstType'],
urlParams: {
transactionType: 'firstType',
serviceName: 'MyServiceName'
}
});
expect(queryByLabelText(container, FILTER_BY_TYPE_LABEL)).toBeNull();
});
});
});

View file

@ -1,115 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TransactionOverviewView should render with type filter controls 1`] = `
<Fragment>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Filter by type"
labelType="label"
>
<EuiSelect
compressed={false}
fullWidth={false}
hasNoInitialSelection={false}
isLoading={false}
onChange={[Function]}
options={
Array [
Object {
"text": "a",
"value": "a",
},
Object {
"text": "b",
"value": "b",
},
]
}
value="test-type"
/>
</EuiFormRow>
<TransactionOverviewChartsRequest
render={[Function]}
urlParams={
Object {
"serviceName": "MyServiceName",
"transactionType": "test-type",
}
}
/>
<EuiSpacer
size="l"
/>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<EuiTitle
size="xs"
textTransform="none"
>
<h3>
Transactions
</h3>
</EuiTitle>
<EuiSpacer
size="s"
/>
<TransactionListRequest
render={[Function]}
urlParams={
Object {
"serviceName": "MyServiceName",
"transactionType": "test-type",
}
}
/>
</EuiPanel>
</Fragment>
`;
exports[`TransactionOverviewView should render without type filter controls if there is just a single type 1`] = `
<Fragment>
<TransactionOverviewChartsRequest
render={[Function]}
urlParams={
Object {
"serviceName": "MyServiceName",
"transactionType": "test-type",
}
}
/>
<EuiSpacer
size="l"
/>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="m"
>
<EuiTitle
size="xs"
textTransform="none"
>
<h3>
Transactions
</h3>
</EuiTitle>
<EuiSpacer
size="s"
/>
<TransactionListRequest
render={[Function]}
urlParams={
Object {
"serviceName": "MyServiceName",
"transactionType": "test-type",
}
}
/>
</EuiPanel>
</Fragment>
`;

View file

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TransactionOverviewView when transactionType is selected and multiple transaction types are given should render dropdown with transaction types 1`] = `
<select
class="euiSelect"
id="transaction-type-select-row"
>
<option
value="firstType"
>
firstType
</option>
<option
value="secondType"
>
secondType
</option>
</select>
`;

View file

@ -12,91 +12,120 @@ import {
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import { first } from 'lodash';
import React from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { TransactionCharts } from 'x-pack/plugins/apm/public/components/shared/charts/TransactionCharts';
import { legacyEncodeURIComponent } from 'x-pack/plugins/apm/public/components/shared/Links/url_helpers';
import { TransactionListRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/transactionList';
import { TransactionOverviewChartsRequest } from 'x-pack/plugins/apm/public/store/reactReduxRequest/transactionOverviewCharts';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { useTransactionList } from '../../../hooks/useTransactionList';
import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts';
import { TransactionList } from './List';
import { useRedirect } from './useRedirect';
interface TransactionOverviewProps extends RouteComponentProps {
urlParams: IUrlParams;
serviceTransactionTypes: string[];
}
export class TransactionOverviewView extends React.Component<
TransactionOverviewProps
> {
public handleTypeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { urlParams, history, location } = this.props;
const type = legacyEncodeURIComponent(event.target.value);
history.push({
function getRedirectLocation({
urlParams,
location,
serviceTransactionTypes
}: {
location: Location;
urlParams: IUrlParams;
serviceTransactionTypes: string[];
}) {
const { serviceName, transactionType } = urlParams;
const firstTransactionType = first(serviceTransactionTypes);
if (!transactionType && firstTransactionType) {
return {
...location,
pathname: `/${urlParams.serviceName}/transactions/${type}`
});
};
public render() {
const { urlParams, serviceTransactionTypes, location } = this.props;
const { serviceName, transactionType } = urlParams;
// filtering by type is currently required
if (!serviceName || !transactionType) {
return null;
}
return (
<React.Fragment>
{serviceTransactionTypes.length > 1 ? (
<EuiFormRow
label={i18n.translate(
'xpack.apm.transactionsTable.filterByTypeLabel',
{
defaultMessage: 'Filter by type'
}
)}
>
<EuiSelect
options={serviceTransactionTypes.map(type => ({
text: `${type}`,
value: type
}))}
value={transactionType}
onChange={this.handleTypeChange}
/>
</EuiFormRow>
) : null}
<TransactionOverviewChartsRequest
urlParams={urlParams}
render={({ data }) => (
<TransactionCharts
charts={data}
location={location}
urlParams={urlParams}
/>
)}
/>
<EuiSpacer size="l" />
<EuiPanel>
<EuiTitle size="xs">
<h3>Transactions</h3>
</EuiTitle>
<EuiSpacer size="s" />
<TransactionListRequest
urlParams={urlParams}
render={({ data }) => (
<TransactionList items={data} serviceName={serviceName} />
)}
/>
</EuiPanel>
</React.Fragment>
);
pathname: `/${serviceName}/transactions/${firstTransactionType}`
};
}
}
export function TransactionOverviewView({
urlParams,
serviceTransactionTypes,
location,
history
}: TransactionOverviewProps) {
const { serviceName, transactionType } = urlParams;
// redirect to first transaction type
useRedirect(
history,
getRedirectLocation({
urlParams,
location,
serviceTransactionTypes
})
);
const { data: transactionOverviewCharts } = useTransactionOverviewCharts(
urlParams
);
const { data: transactionListData } = useTransactionList(urlParams);
// filtering by type is currently required
if (!serviceName || !transactionType) {
return null;
}
return (
<React.Fragment>
{serviceTransactionTypes.length > 1 ? (
<EuiFormRow
id="transaction-type-select-row"
label={i18n.translate(
'xpack.apm.transactionsTable.filterByTypeLabel',
{
defaultMessage: 'Filter by type'
}
)}
>
<EuiSelect
options={serviceTransactionTypes.map(type => ({
text: `${type}`,
value: type
}))}
value={transactionType}
onChange={event => {
const type = legacyEncodeURIComponent(event.target.value);
history.push({
...location,
pathname: `/${urlParams.serviceName}/transactions/${type}`
});
}}
/>
</EuiFormRow>
) : null}
<TransactionCharts
charts={transactionOverviewCharts}
location={location}
urlParams={urlParams}
/>
<EuiSpacer size="l" />
<EuiPanel>
<EuiTitle size="xs">
<h3>Transactions</h3>
</EuiTitle>
<EuiSpacer size="s" />
<TransactionList
items={transactionListData}
serviceName={serviceName}
/>
</EuiPanel>
</React.Fragment>
);
}
export const TransactionOverview = withRouter(TransactionOverviewView);

View file

@ -0,0 +1,16 @@
/*
* 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 { History, Location } from 'history';
import { useEffect } from 'react';
export function useRedirect(history: History, redirectLocation?: Location) {
useEffect(() => {
if (redirectLocation) {
history.replace(redirectLocation);
}
});
}

View file

@ -11,7 +11,7 @@ import { MemoryRouter } from 'react-router-dom';
import { Store } from 'redux';
// @ts-ignore
import configureStore from 'x-pack/plugins/apm/public/store/config/configureStore';
import { mockNow } from 'x-pack/plugins/apm/public/utils/testHelpers';
import { mockNow, tick } from 'x-pack/plugins/apm/public/utils/testHelpers';
import { DatePicker, DatePickerComponent } from '../DatePicker';
function mountPicker(initialState = {}) {
@ -54,8 +54,6 @@ describe('DatePicker', () => {
});
});
const tick = () => new Promise(resolve => setImmediate(resolve, 0));
describe('refresh cycle', () => {
let nowSpy: jest.Mock;
beforeEach(() => {

View file

@ -4,17 +4,220 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { selectHasMLJob } from 'x-pack/plugins/apm/public/store/reactReduxRequest/transactionOverviewCharts';
import { IReduxState } from 'x-pack/plugins/apm/public/store/rootReducer';
import { selectIsMLAvailable } from 'x-pack/plugins/apm/public/store/selectors/license';
import { TransactionChartsView } from './view';
import {
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
EuiPanel,
EuiText,
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React, { Component } from 'react';
import styled from 'styled-components';
import { MLJobLink } from 'x-pack/plugins/apm/public/components/shared/Links/MLJobLink';
import { ITransactionChartData } from 'x-pack/plugins/apm/public/store/selectors/chartSelectors';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { Coordinate } from 'x-pack/plugins/apm/typings/timeseries';
import { asInteger, asMillis, tpmUnit } from '../../../../utils/formatters';
import { LicenseContext } from '../../../app/Main/LicenseCheck';
// @ts-ignore
import CustomPlot from '../CustomPlot';
import { SyncChartGroup } from '../SyncChartGroup';
const mapStateToProps = (state: IReduxState) => ({
mlAvailable: selectIsMLAvailable(state),
hasMLJob: selectHasMLJob(state)
});
interface TransactionChartProps {
charts: ITransactionChartData;
location: Location;
urlParams: IUrlParams;
}
export const TransactionCharts = connect(mapStateToProps)(
TransactionChartsView
const ShiftedIconWrapper = styled.span`
padding-right: 5px;
position: relative;
top: -1px;
display: inline-block;
`;
const ShiftedEuiText = styled(EuiText)`
position: relative;
top: 5px;
`;
const msTimeUnitLabel = i18n.translate(
'xpack.apm.metrics.transactionChart.msTimeUnitLabel',
{
defaultMessage: 'ms'
}
);
export class TransactionCharts extends Component<TransactionChartProps> {
public getResponseTimeTickFormatter = (t: number) => {
return this.props.charts.noHits ? `- ${msTimeUnitLabel}` : asMillis(t);
};
public getResponseTimeTooltipFormatter = (p: Coordinate) => {
return this.props.charts.noHits || !p
? `- ${msTimeUnitLabel}`
: asMillis(p.y);
};
public getTPMFormatter = (t: number | null) => {
const { urlParams, charts } = this.props;
const unit = tpmUnit(urlParams.transactionType);
return charts.noHits || t === null
? `- ${unit}`
: `${asInteger(t)} ${unit}`;
};
public getTPMTooltipFormatter = (p: Coordinate) => {
return this.getTPMFormatter(p.y);
};
public renderMLHeader(mlAvailable: boolean) {
const hasMLJob = this.props.charts.hasMLJob;
if (!mlAvailable || !hasMLJob) {
return null;
}
const { serviceName, transactionType } = this.props.urlParams;
if (!serviceName) {
return null;
}
return (
<EuiFlexItem grow={false}>
<ShiftedEuiText size="xs">
<ShiftedIconWrapper>
<EuiIconTip
content={i18n.translate(
'xpack.apm.metrics.transactionChart.machineLearningTooltip',
{
defaultMessage:
'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.'
}
)}
/>
</ShiftedIconWrapper>
<span>
{i18n.translate(
'xpack.apm.metrics.transactionChart.machineLearningLabel',
{
defaultMessage: 'Machine learning:'
}
)}{' '}
</span>
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={this.props.location}
>
View Job
</MLJobLink>
</ShiftedEuiText>
</EuiFlexItem>
);
}
public render() {
const { charts, urlParams } = this.props;
const { noHits, responseTimeSeries, tpmSeries } = charts;
const { transactionType } = urlParams;
return (
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiPanel>
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xs">
<span>{responseTimeLabel(transactionType)}</span>
</EuiTitle>
</EuiFlexItem>
<LicenseContext.Consumer>
{license =>
this.renderMLHeader(license.features.ml.is_available)
}
</LicenseContext.Consumer>
</EuiFlexGroup>
<CustomPlot
noHits={noHits}
series={responseTimeSeries}
{...hoverXHandlers}
tickFormatY={this.getResponseTimeTickFormatter}
formatTooltipValue={this.getResponseTimeTooltipFormatter}
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<EuiPanel>
<React.Fragment>
<EuiTitle size="xs">
<span>{tpmLabel(transactionType)}</span>
</EuiTitle>
<CustomPlot
noHits={noHits}
series={tpmSeries}
{...hoverXHandlers}
tickFormatY={this.getTPMFormatter}
formatTooltipValue={this.getTPMTooltipFormatter}
truncateLegends
/>
</React.Fragment>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
)}
/>
);
}
}
function tpmLabel(type?: string) {
return type === 'request'
? i18n.translate(
'xpack.apm.metrics.transactionChart.requestsPerMinuteLabel',
{
defaultMessage: 'Requests per minute'
}
)
: i18n.translate(
'xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel',
{
defaultMessage: 'Transactions per minute'
}
);
}
function responseTimeLabel(type?: string) {
switch (type) {
case 'page-load':
return i18n.translate(
'xpack.apm.metrics.transactionChart.pageLoadTimesLabel',
{
defaultMessage: 'Page load times'
}
);
case 'route-change':
return i18n.translate(
'xpack.apm.metrics.transactionChart.routeChangeTimesLabel',
{
defaultMessage: 'Route change times'
}
);
default:
return i18n.translate(
'xpack.apm.metrics.transactionChart.transactionDurationLabel',
{
defaultMessage: 'Transaction duration'
}
);
}
}

View file

@ -1,224 +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 {
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
EuiPanel,
EuiText,
EuiTitle
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React, { Component } from 'react';
import styled from 'styled-components';
import { MLJobLink } from 'x-pack/plugins/apm/public/components/shared/Links/MLJobLink';
import { ITransactionChartData } from 'x-pack/plugins/apm/public/store/selectors/chartSelectors';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { Coordinate } from 'x-pack/plugins/apm/typings/timeseries';
import { asInteger, asMillis, tpmUnit } from '../../../../utils/formatters';
// @ts-ignore
import CustomPlot from '../CustomPlot';
import { SyncChartGroup } from '../SyncChartGroup';
interface TransactionChartProps {
charts: ITransactionChartData;
chartWrapper?: React.ComponentClass | React.SFC;
location: Location;
urlParams: IUrlParams;
mlAvailable: boolean;
hasMLJob: boolean;
}
const ShiftedIconWrapper = styled.span`
padding-right: 5px;
position: relative;
top: -1px;
display: inline-block;
`;
const ShiftedEuiText = styled(EuiText)`
position: relative;
top: 5px;
`;
const msTimeUnitLabel = i18n.translate(
'xpack.apm.metrics.transactionChart.msTimeUnitLabel',
{
defaultMessage: 'ms'
}
);
export class TransactionChartsView extends Component<TransactionChartProps> {
public getResponseTimeTickFormatter = (t: number) => {
return this.props.charts.noHits ? `- ${msTimeUnitLabel}` : asMillis(t);
};
public getResponseTimeTooltipFormatter = (p: Coordinate) => {
return this.props.charts.noHits || !p
? `- ${msTimeUnitLabel}`
: asMillis(p.y);
};
public getTPMFormatter = (t: number | null) => {
const { urlParams, charts } = this.props;
const unit = tpmUnit(urlParams.transactionType);
return charts.noHits || t === null
? `- ${unit}`
: `${asInteger(t)} ${unit}`;
};
public getTPMTooltipFormatter = (p: Coordinate) => {
return this.getTPMFormatter(p.y);
};
public renderMLHeader() {
if (!this.props.mlAvailable || !this.props.hasMLJob) {
return null;
}
const { serviceName, transactionType } = this.props.urlParams;
if (!serviceName) {
return null;
}
return (
<EuiFlexItem grow={false}>
<ShiftedEuiText size="xs">
<ShiftedIconWrapper>
<EuiIconTip
content={i18n.translate(
'xpack.apm.metrics.transactionChart.machineLearningTooltip',
{
defaultMessage:
'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.'
}
)}
/>
</ShiftedIconWrapper>
<span>
{i18n.translate(
'xpack.apm.metrics.transactionChart.machineLearningLabel',
{
defaultMessage: 'Machine learning:'
}
)}{' '}
</span>
<MLJobLink
serviceName={serviceName}
transactionType={transactionType}
location={this.props.location}
>
View Job
</MLJobLink>
</ShiftedEuiText>
</EuiFlexItem>
);
}
public render() {
const {
charts,
urlParams,
chartWrapper: ChartWrapper = React.Fragment
} = this.props;
const { noHits, responseTimeSeries, tpmSeries } = charts;
const { transactionType } = urlParams;
return (
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2}>
<EuiFlexItem>
<EuiPanel>
<ChartWrapper>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="xs">
<span>{responseTimeLabel(transactionType)}</span>
</EuiTitle>
</EuiFlexItem>
{this.renderMLHeader()}
</EuiFlexGroup>
<CustomPlot
noHits={noHits}
series={responseTimeSeries}
{...hoverXHandlers}
tickFormatY={this.getResponseTimeTickFormatter}
formatTooltipValue={this.getResponseTimeTooltipFormatter}
/>
</ChartWrapper>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem style={{ flexShrink: 1 }}>
<EuiPanel>
<ChartWrapper>
<EuiTitle size="xs">
<span>{tpmLabel(transactionType)}</span>
</EuiTitle>
<CustomPlot
noHits={noHits}
series={tpmSeries}
{...hoverXHandlers}
tickFormatY={this.getTPMFormatter}
formatTooltipValue={this.getTPMTooltipFormatter}
truncateLegends
/>
</ChartWrapper>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGrid>
)}
/>
);
}
}
function tpmLabel(type?: string) {
return type === 'request'
? i18n.translate(
'xpack.apm.metrics.transactionChart.requestsPerMinuteLabel',
{
defaultMessage: 'Requests per minute'
}
)
: i18n.translate(
'xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel',
{
defaultMessage: 'Transactions per minute'
}
);
}
function responseTimeLabel(type?: string) {
switch (type) {
case 'page-load':
return i18n.translate(
'xpack.apm.metrics.transactionChart.pageLoadTimesLabel',
{
defaultMessage: 'Page load times'
}
);
case 'route-change':
return i18n.translate(
'xpack.apm.metrics.transactionChart.routeChangeTimesLabel',
{
defaultMessage: 'Route change times'
}
);
default:
return i18n.translate(
'xpack.apm.metrics.transactionChart.transactionDurationLabel',
{
defaultMessage: 'Transaction duration'
}
);
}
}

View file

@ -0,0 +1,121 @@
/*
* 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 { render } from 'react-testing-library';
import { delay, tick } from '../utils/testHelpers';
import { useFetcher } from './useFetcher';
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* tslint:disable:no-console */
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
async function asyncFn(name: string, ms: number) {
await delay(ms);
return `Hello from ${name}`;
}
describe('when simulating race condition', () => {
let requestCallOrder: Array<[string, string, number]>;
let renderSpy: jest.Mock;
beforeEach(async () => {
jest.useFakeTimers();
jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(cb => cb(0) as any);
renderSpy = jest.fn();
requestCallOrder = [];
function MyComponent({
name,
ms,
renderFn
}: {
name: string;
ms: number;
renderFn: any;
}) {
const { data, status, error } = useFetcher(
async () => {
requestCallOrder.push(['request', name, ms]);
const res = await asyncFn(name, ms);
requestCallOrder.push(['response', name, ms]);
return res;
},
[name, ms]
);
renderFn({ data, status, error });
return null;
}
const { rerender } = render(
<MyComponent name="John" ms={500} renderFn={renderSpy} />
);
rerender(<MyComponent name="Peter" ms={100} renderFn={renderSpy} />);
});
it('should render initially render loading state', async () => {
expect(renderSpy).lastCalledWith({
data: undefined,
error: undefined,
status: 'loading'
});
});
it('should render "Hello from Peter" after 200ms', async () => {
jest.advanceTimersByTime(200);
await tick();
expect(renderSpy).lastCalledWith({
data: 'Hello from Peter',
error: undefined,
status: 'success'
});
});
it('should render "Hello from Peter" after 600ms', async () => {
jest.advanceTimersByTime(600);
await tick();
expect(renderSpy).lastCalledWith({
data: 'Hello from Peter',
error: undefined,
status: 'success'
});
});
it('should should NOT have rendered "Hello from John" at any point', async () => {
jest.advanceTimersByTime(600);
await tick();
expect(renderSpy).not.toHaveBeenCalledWith({
data: 'Hello from John',
error: undefined,
status: 'success'
});
});
it('should send and receive calls in the right order', async () => {
jest.advanceTimersByTime(600);
await tick();
expect(requestCallOrder).toEqual([
['request', 'John', 500],
['request', 'Peter', 100],
['response', 'Peter', 100],
['response', 'John', 500]
]);
});
});

View file

@ -0,0 +1,159 @@
/*
* 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 { cleanup, renderHook } from 'react-hooks-testing-library';
import { delay } from '../utils/testHelpers';
import { useFetcher } from './useFetcher';
afterEach(cleanup);
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* tslint:disable:no-console */
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
describe('useFetcher', () => {
describe('when resolving after 500ms', () => {
let hook: ReturnType<typeof renderHook>;
beforeEach(() => {
jest.useFakeTimers();
async function fn() {
await delay(500);
return 'response from hook';
}
hook = renderHook(() => useFetcher(() => fn(), []));
});
it('should initially be empty', async () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
status: undefined
});
});
it('should show loading spinner after 100ms', async () => {
jest.advanceTimersByTime(100);
await hook.waitForNextUpdate();
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
status: 'loading'
});
});
it('should show success after 1 second', async () => {
jest.advanceTimersByTime(1000);
await hook.waitForNextUpdate();
expect(hook.result.current).toEqual({
data: 'response from hook',
error: undefined,
status: 'success'
});
});
});
describe('when throwing after 500ms', () => {
let hook: ReturnType<typeof renderHook>;
beforeEach(() => {
jest.useFakeTimers();
async function fn() {
await delay(500);
throw new Error('Something went wrong');
}
hook = renderHook(() => useFetcher(() => fn(), []));
});
it('should initially be empty', async () => {
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
status: undefined
});
});
it('should show loading spinner after 100ms', async () => {
jest.advanceTimersByTime(100);
await hook.waitForNextUpdate();
expect(hook.result.current).toEqual({
data: undefined,
error: undefined,
status: 'loading'
});
});
it('should show error after 1 second', async () => {
jest.advanceTimersByTime(1000);
await hook.waitForNextUpdate();
expect(hook.result.current).toEqual({
data: undefined,
error: expect.any(Error),
status: 'failure'
});
});
});
describe('when a hook already has data', () => {
it('should show "first response" while loading "second response"', async () => {
jest.useFakeTimers();
const hook = renderHook(
({ callback, args }) => useFetcher(callback, args),
{
initialProps: {
callback: async () => 'first response',
args: ['a']
}
}
);
await hook.waitForNextUpdate();
// assert: first response has loaded and should be rendered
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
status: 'success'
});
// act: re-render hook with async callback
hook.rerender({
callback: async () => {
await delay(500);
return 'second response';
},
args: ['b']
});
jest.advanceTimersByTime(100);
await hook.waitForNextUpdate();
// assert: while loading new data the previous data should still be rendered
expect(hook.result.current).toEqual({
data: 'first response',
error: undefined,
status: 'loading'
});
jest.advanceTimersByTime(500);
await hook.waitForNextUpdate();
// assert: "second response" has loaded and should be rendered
expect(hook.result.current).toEqual({
data: 'second response',
error: undefined,
status: 'success'
});
});
});
});

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useContext, useEffect, useState } from 'react';
import { GlobalFetchContext } from '../components/app/Main/GlobalFetchIndicator';
export enum FETCH_STATUS {
LOADING = 'loading',
SUCCESS = 'success',
FAILURE = 'failure'
}
// use this in request methods to signal to `useFetch` that all arguments are not yet available
export class MissingArgumentsError extends Error {}
export function useFetcher<Response>(
fn: () => Promise<Response>,
useEffectKey: Array<string | boolean | number | undefined>
) {
const { dispatchStatus } = useContext(GlobalFetchContext);
const [result, setResult] = useState<{
data?: Response;
status?: FETCH_STATUS;
error?: Error;
}>({});
useEffect(() => {
let didCancel = false;
let didFinish = false;
// only apply the loading indicator if the promise did not resolve immediately
// the promise will resolve immediately if the value was found in cache
requestAnimationFrame(() => {
if (!didFinish && !didCancel) {
dispatchStatus({ name: fn.name, isLoading: true });
setResult({
data: result.data, // preserve data from previous state while loading next state
status: FETCH_STATUS.LOADING,
error: undefined
});
}
});
async function doFetch() {
try {
const data = await fn();
if (!didCancel) {
dispatchStatus({ name: fn.name, isLoading: false });
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined
});
}
} catch (e) {
if (e instanceof MissingArgumentsError) {
return;
}
if (!didCancel) {
dispatchStatus({ name: fn.name, isLoading: false });
setResult({
data: undefined,
status: FETCH_STATUS.FAILURE,
error: e
});
}
}
didFinish = true;
}
doFetch();
return () => {
dispatchStatus({ name: fn.name, isLoading: false });
didCancel = true;
};
}, useEffectKey);
return result || {};
}

View file

@ -0,0 +1,85 @@
/*
* 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 { useMemo } from 'react';
import { MetricsChartAPIResponse } from '../../server/lib/metrics/get_all_metrics_chart_data';
import { MemoryChartAPIResponse } from '../../server/lib/metrics/get_memory_chart_data';
import { loadMetricsChartDataForService } from '../services/rest/apm/metrics';
import {
getCPUSeries,
getMemorySeries
} from '../store/selectors/chartSelectors';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
const memory: MemoryChartAPIResponse = {
series: {
memoryUsedAvg: [],
memoryUsedMax: []
},
overallValues: {
memoryUsedAvg: null,
memoryUsedMax: null
},
totalHits: 0
};
const INITIAL_DATA: MetricsChartAPIResponse = {
memory,
cpu: {
series: {
systemCPUAverage: [],
systemCPUMax: [],
processCPUAverage: [],
processCPUMax: []
},
overallValues: {
systemCPUAverage: null,
systemCPUMax: null,
processCPUAverage: null,
processCPUMax: null
},
totalHits: 0
}
};
export function useServiceMetricCharts(urlParams: IUrlParams) {
const {
serviceName,
transactionType,
start,
end,
transactionName,
kuery
} = urlParams;
const { data = INITIAL_DATA, error, status } = useFetcher(
() =>
loadMetricsChartDataForService({
serviceName,
transactionName,
transactionType,
start,
end,
kuery
}),
[serviceName, transactionName, transactionType, start, end, kuery]
);
const memoizedData = useMemo(
() => ({
memory: getMemorySeries(urlParams, data.memory),
cpu: getCPUSeries(data.cpu)
}),
[data]
);
return {
data: memoizedData,
status,
error
};
}

View file

@ -0,0 +1,45 @@
/*
* 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 { useMemo } from 'react';
import { loadTransactionDetailsCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../store/selectors/chartSelectors';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
export function useTransactionDetailsCharts(urlParams: IUrlParams) {
const {
serviceName,
transactionType,
start,
end,
transactionName,
kuery
} = urlParams;
const { data, error, status } = useFetcher(
() =>
loadTransactionDetailsCharts({
serviceName,
transactionName,
transactionType,
start,
end,
kuery
}),
[serviceName, transactionName, transactionType, start, end, kuery]
);
const memoizedData = useMemo(() => getTransactionCharts(urlParams, data), [
data
]);
return {
data: memoizedData,
status,
error
};
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { loadTransactionDistribution } from '../services/rest/apm/transaction_groups';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
const INITIAL_DATA = { buckets: [], totalHits: 0, bucketSize: 0 };
export function useTransactionDistribution(urlParams: IUrlParams) {
const {
serviceName,
transactionType,
transactionId,
traceId,
start,
end,
transactionName,
kuery
} = urlParams;
const { data = INITIAL_DATA, status, error } = useFetcher(
() =>
loadTransactionDistribution({
serviceName,
transactionType,
transactionId,
traceId,
start,
end,
transactionName,
kuery
}),
[
serviceName,
transactionType,
transactionId,
traceId,
start,
end,
transactionName,
kuery
]
);
return { data, status, error };
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useMemo } from 'react';
import { TransactionListAPIResponse } from '../../server/lib/transactions/get_top_transactions';
import { loadTransactionList } from '../services/rest/apm/transaction_groups';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
const getRelativeImpact = (
impact: number,
impactMin: number,
impactMax: number
) =>
Math.max(
((impact - impactMin) / Math.max(impactMax - impactMin, 1)) * 100,
1
);
function getWithRelativeImpact(items: TransactionListAPIResponse) {
const impacts = items.map(({ impact }) => impact);
const impactMin = Math.min(...impacts);
const impactMax = Math.max(...impacts);
return items.map(item => {
return {
...item,
impactRelative: getRelativeImpact(item.impact, impactMin, impactMax)
};
});
}
export function useTransactionList(urlParams: IUrlParams) {
const { serviceName, transactionType, start, end, kuery } = urlParams;
const { data = [], error, status } = useFetcher(
() =>
loadTransactionList({ serviceName, start, end, transactionType, kuery }),
[serviceName, start, end, transactionType, kuery]
);
const memoizedData = useMemo(() => getWithRelativeImpact(data), [data]);
return {
data: memoizedData,
status,
error
};
}

View file

@ -0,0 +1,44 @@
/*
* 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 { useMemo } from 'react';
import { loadTransactionOverviewCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../store/selectors/chartSelectors';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
export function useTransactionOverviewCharts(urlParams: IUrlParams) {
const {
serviceName,
transactionType,
start,
end,
kuery
} = urlParams;
const { data, error, status } = useFetcher(
() =>
loadTransactionOverviewCharts({
serviceName,
start,
end,
transactionType,
kuery
}),
[serviceName, start, end, transactionType, kuery]
);
const memoizedData = useMemo(() => getTransactionCharts(urlParams, data), [
data
]);
return {
data: memoizedData,
status,
error
};
}

View file

@ -0,0 +1,28 @@
/*
* 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 { useMemo } from 'react';
import { getWaterfall } from '../components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
import { loadTrace } from '../services/rest/apm/traces';
import { IUrlParams } from '../store/urlParams';
import { useFetcher } from './useFetcher';
const INITIAL_DATA = { trace: [], errorsPerTransaction: {} };
export function useWaterfall(urlParams: IUrlParams) {
const { traceId, start, end, transactionId } = urlParams;
const { data = INITIAL_DATA, status, error } = useFetcher(
() => loadTrace({ traceId, start, end }),
[traceId, start, end]
);
const waterfall = useMemo(() => getWaterfall(data, transactionId), [
data,
transactionId
]);
return { data: waterfall, status, error };
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
@ -18,7 +18,6 @@ import { uiModules } from 'ui/modules';
import 'uiExports/autocompleteProviders';
import { GlobalHelpExtension } from './components/app/GlobalHelpExtension';
import { Main } from './components/app/Main';
import { GlobalProgress } from './components/app/Main/GlobalProgress';
import { history } from './components/shared/Links/url_helpers';
// @ts-ignore
import configureStore from './store/config/configureStore';
@ -53,12 +52,9 @@ waitForRoot.then(() => {
ReactDOM.render(
<I18nContext>
<Provider store={store}>
<Fragment>
<GlobalProgress />
<Router history={history}>
<Main />
</Router>
</Fragment>
<Router history={history}>
<Main />
</Router>
</Provider>
</I18nContext>,
document.getElementById(REACT_APP_ROOT_ID)

View file

@ -5,7 +5,8 @@
*/
import * as kfetchModule from 'ui/kfetch';
import { callApi } from '../rest/callApi';
import { mockNow } from '../../utils/testHelpers';
import { _clearCache, callApi } from '../rest/callApi';
import { SessionStorageMock } from './SessionStorageMock';
describe('callApi', () => {
@ -21,41 +22,148 @@ describe('callApi', () => {
afterEach(() => {
kfetchSpy.mockClear();
_clearCache();
});
describe('callApi', () => {
describe('apm_debug', () => {
beforeEach(() => {
sessionStorage.setItem('apm_debug', 'true');
});
describe('apm_debug', () => {
beforeEach(() => {
sessionStorage.setItem('apm_debug', 'true');
});
it('should add debug param for APM endpoints', async () => {
await callApi({ pathname: `/api/apm/status/server` });
it('should add debug param for APM endpoints', async () => {
await callApi({ pathname: `/api/apm/status/server` });
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/apm/status/server', query: { _debug: true } },
undefined
);
});
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/apm/status/server', query: { _debug: true } },
undefined
);
});
it('should not add debug param for non-APM endpoints', async () => {
await callApi({ pathname: `/api/kibana` });
it('should not add debug param for non-APM endpoints', async () => {
await callApi({ pathname: `/api/kibana` });
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/kibana' },
undefined
);
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/kibana' },
undefined
);
});
});
describe('prependBasePath', () => {
it('should be passed on to kFetch', async () => {
await callApi({ pathname: `/api/kibana` }, { prependBasePath: false });
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/kibana' },
{ prependBasePath: false }
);
});
});
describe('cache', () => {
let nowSpy: jest.Mock;
beforeEach(() => {
nowSpy = mockNow('2019');
});
beforeEach(() => {
nowSpy.mockRestore();
});
describe('when the call does not contain start/end params', () => {
it('should not return cached response for identical calls', async () => {
await callApi({ pathname: `/api/kibana`, query: { foo: 'bar' } });
await callApi({ pathname: `/api/kibana`, query: { foo: 'bar' } });
await callApi({ pathname: `/api/kibana`, query: { foo: 'bar' } });
expect(kfetchSpy).toHaveBeenCalledTimes(3);
});
});
describe('prependBasePath', () => {
it('should be passed on to kFetch', async () => {
await callApi({ pathname: `/api/kibana` }, { prependBasePath: false });
describe('when the call contains start/end params', () => {
it('should return cached response for identical calls', async () => {
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011' }
});
expect(kfetchSpy).toHaveBeenCalledWith(
{ pathname: '/api/kibana' },
{ prependBasePath: false }
);
expect(kfetchSpy).toHaveBeenCalledTimes(1);
});
it('should not return cached response for subsequent calls if arguments change', async () => {
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011', foo: 'bar1' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011', foo: 'bar2' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2010', end: '2011', foo: 'bar3' }
});
expect(kfetchSpy).toHaveBeenCalledTimes(3);
});
it('should not return cached response if `end` is a future timestamp', async () => {
await callApi({
pathname: `/api/kibana`,
query: { end: '2030' }
});
await callApi({
pathname: `/api/kibana`,
query: { end: '2030' }
});
await callApi({
pathname: `/api/kibana`,
query: { end: '2030' }
});
expect(kfetchSpy).toHaveBeenCalledTimes(3);
});
it('should return cached response if calls contain `end` param in the past', async () => {
await callApi({
pathname: `/api/kibana`,
query: { start: '2009', end: '2010' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2009', end: '2010' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2009', end: '2010' }
});
expect(kfetchSpy).toHaveBeenCalledTimes(1);
});
it('should return cached response even if order of properties change', async () => {
await callApi({
pathname: `/api/kibana`,
query: { end: '2010', start: '2009' }
});
await callApi({
pathname: `/api/kibana`,
query: { start: '2009', end: '2010' }
});
await callApi({
query: { start: '2009', end: '2010' },
pathname: `/api/kibana`
});
expect(kfetchSpy).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -7,29 +7,27 @@
import { ErrorDistributionAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/distribution/get_distribution';
import { ErrorGroupAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_group';
import { ErrorGroupListAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_groups';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';
interface ErrorGroupListParams extends IUrlParams {
size: number;
}
export async function loadErrorGroupList({
serviceName,
start,
end,
kuery,
size,
sortField,
sortDirection
}: ErrorGroupListParams) {
}: IUrlParams) {
if (!(serviceName && start && end)) {
throw new MissingArgumentsError();
}
return callApi<ErrorGroupListAPIResponse>({
pathname: `/api/apm/services/${serviceName}/errors`,
query: {
start,
end,
size,
sortField,
sortDirection,
esFilterQuery: await getEncodedEsQuery(kuery)
@ -44,6 +42,9 @@ export async function loadErrorGroupDetails({
kuery,
errorGroupId
}: IUrlParams) {
if (!(serviceName && start && end && errorGroupId)) {
throw new MissingArgumentsError();
}
return callApi<ErrorGroupAPIResponse>({
pathname: `/api/apm/services/${serviceName}/errors/${errorGroupId}`,
query: {
@ -61,6 +62,10 @@ export async function loadErrorDistribution({
kuery,
errorGroupId
}: IUrlParams) {
if (!(serviceName && start && end)) {
throw new MissingArgumentsError();
}
const pathname = errorGroupId
? `/api/apm/services/${serviceName}/errors/${errorGroupId}/distribution`
: `/api/apm/services/${serviceName}/errors/distribution`;

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { MetricsChartAPIResponse } from 'x-pack/plugins/apm/server/lib/metrics/get_all_metrics_chart_data';
import { IUrlParams } from '../../../store/urlParams';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';
@ -14,7 +15,7 @@ export async function loadMetricsChartDataForService({
end,
kuery
}: IUrlParams) {
return callApi({
return callApi<MetricsChartAPIResponse>({
pathname: `/api/apm/services/${serviceName}/metrics/charts`,
query: {
start,

View file

@ -6,11 +6,16 @@
import { ServiceAPIResponse } from 'x-pack/plugins/apm/server/lib/services/get_service';
import { ServiceListAPIResponse } from 'x-pack/plugins/apm/server/lib/services/get_services';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';
export async function loadServiceList({ start, end, kuery }: IUrlParams) {
if (!(start && end)) {
throw new MissingArgumentsError();
}
return callApi<ServiceListAPIResponse>({
pathname: `/api/apm/services`,
query: {
@ -27,6 +32,10 @@ export async function loadServiceDetails({
end,
kuery
}: IUrlParams) {
if (!(serviceName && start && end)) {
throw new MissingArgumentsError();
}
return callApi<ServiceAPIResponse>({
pathname: `/api/apm/services/${serviceName}`,
query: {

View file

@ -13,7 +13,9 @@ export async function loadServerStatus() {
}
export async function loadAgentStatus() {
return callApi<{ dataFound: boolean }>({
const res = await callApi<{ dataFound: boolean }>({
pathname: `/api/apm/status/agent`
});
return res.dataFound;
}

View file

@ -6,11 +6,16 @@
import { TraceListAPIResponse } from 'x-pack/plugins/apm/server/lib/traces/get_top_traces';
import { TraceAPIResponse } from 'x-pack/plugins/apm/server/lib/traces/get_trace';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';
export async function loadTrace({ traceId, start, end }: IUrlParams) {
if (!(traceId && start && end)) {
throw new MissingArgumentsError();
}
return callApi<TraceAPIResponse>({
pathname: `/api/apm/traces/${traceId}`,
query: {
@ -21,6 +26,10 @@ export async function loadTrace({ traceId, start, end }: IUrlParams) {
}
export async function loadTraceList({ start, end, kuery }: IUrlParams) {
if (!(start && end)) {
throw new MissingArgumentsError();
}
return callApi<TraceListAPIResponse>({
pathname: '/api/apm/traces',
query: {

View file

@ -7,6 +7,7 @@
import { TimeSeriesAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/charts';
import { ITransactionDistributionAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/distribution';
import { TransactionListAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/get_top_transactions';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';
@ -16,8 +17,12 @@ export async function loadTransactionList({
start,
end,
kuery,
transactionType = 'request'
transactionType
}: IUrlParams) {
if (!(serviceName && transactionType && start && end)) {
throw new MissingArgumentsError();
}
return await callApi<TransactionListAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/${transactionType}`,
query: {
@ -33,11 +38,15 @@ export async function loadTransactionDistribution({
start,
end,
transactionName,
transactionType = 'request',
transactionType,
transactionId,
traceId,
kuery
}: Required<IUrlParams>) {
}: IUrlParams) {
if (!(serviceName && transactionName && transactionType && start && end)) {
throw new MissingArgumentsError();
}
return callApi<ITransactionDistributionAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/${transactionType}/${encodeURIComponent(
transactionName
@ -52,14 +61,18 @@ export async function loadTransactionDistribution({
});
}
export async function loadDetailsCharts({
export async function loadTransactionDetailsCharts({
serviceName,
start,
end,
kuery,
transactionType = 'request',
transactionType,
transactionName
}: Required<IUrlParams>) {
}: IUrlParams) {
if (!(serviceName && transactionName && transactionType && start && end)) {
throw new MissingArgumentsError();
}
return callApi<TimeSeriesAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/${transactionType}/${encodeURIComponent(
transactionName
@ -72,31 +85,23 @@ export async function loadDetailsCharts({
});
}
export async function loadOverviewCharts({
export async function loadTransactionOverviewCharts({
serviceName,
start,
end,
kuery,
transactionType = 'request'
transactionType
}: IUrlParams) {
return callApi<TimeSeriesAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/${transactionType}/charts`,
query: {
start,
end,
esFilterQuery: await getEncodedEsQuery(kuery)
}
});
}
if (!(serviceName && start && end)) {
throw new MissingArgumentsError();
}
const pathname = transactionType
? `/api/apm/services/${serviceName}/transaction_groups/${transactionType}/charts`
: `/api/apm/services/${serviceName}/transaction_groups/charts`;
export async function loadOverviewChartsForAllTypes({
serviceName,
start,
end,
kuery
}: IUrlParams) {
return callApi<TimeSeriesAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/charts`,
pathname,
query: {
start,
end,

View file

@ -4,7 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { startsWith } from 'lodash';
import { FetchOptions } from 'apollo-link-http';
import { isString, startsWith } from 'lodash';
import LRU from 'lru-cache';
import hash from 'object-hash';
import { kfetch, KFetchOptions } from 'ui/kfetch';
import { KFetchKibanaOptions } from 'ui/kfetch/kfetch';
@ -26,10 +29,49 @@ function fetchOptionsWithDebug(fetchOptions: KFetchOptions) {
};
}
const cache = new LRU<string, any>({ max: 100, maxAge: 1000 * 60 * 60 });
export function _clearCache() {
cache.reset();
}
export async function callApi<T = void>(
fetchOptions: KFetchOptions,
options?: KFetchKibanaOptions
): Promise<T> {
const cacheKey = getCacheKey(fetchOptions);
const cacheResponse = cache.get(cacheKey);
if (cacheResponse) {
return cacheResponse;
}
const combinedFetchOptions = fetchOptionsWithDebug(fetchOptions);
return await kfetch(combinedFetchOptions, options);
const res = await kfetch(combinedFetchOptions, options);
if (isCachable(fetchOptions)) {
cache.set(cacheKey, res);
}
return res;
}
// only cache items that has a time range with `start` and `end` params,
// and where `end` is not a timestamp in the future
function isCachable(fetchOptions: KFetchOptions) {
if (
!(fetchOptions.query && fetchOptions.query.start && fetchOptions.query.end)
) {
return false;
}
return (
isString(fetchOptions.query.end) &&
new Date(fetchOptions.query.end).getTime() < Date.now()
);
}
// order the options object to make sure that two objects with the same arguments, produce produce the
// same cache key regardless of the order of properties
function getCacheKey(options: FetchOptions) {
return hash(options);
}

View file

@ -12,7 +12,6 @@ describe('root reducer', () => {
expect(state).toEqual({
location: { hash: '', pathname: '', search: '' },
reactReduxRequest: {},
urlParams: {}
});
});

View file

@ -1,31 +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 { createInitialDataSelector } from '../helpers';
describe('createInitialDataSelector', () => {
it('should use initialData when data is missing from state', () => {
const state = {};
const initialData = { foo: 'bar' };
const withInitialData = createInitialDataSelector(initialData);
expect(withInitialData(state)).toBe(withInitialData(state));
expect(withInitialData(state, initialData)).toEqual({
data: { foo: 'bar' }
});
});
it('should use data when available', () => {
const state = { data: 'hello' };
const initialData = { foo: 'bar' };
const withInitialData = createInitialDataSelector(initialData);
expect(withInitialData(state)).toBe(withInitialData(state));
expect(withInitialData(state, initialData)).toEqual({
data: 'hello'
});
});
});

View file

@ -1,71 +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 React from 'react';
import * as rest from '../../../services/rest/apm/services';
import { getServiceList, ServiceListRequest } from '../serviceList';
import { mountWithStore } from '../../../utils/testHelpers';
describe('serviceList', () => {
describe('getServiceList', () => {
it('should return default value when empty', () => {
const state = { reactReduxRequest: {}, sorting: { service: {} } };
expect(getServiceList(state)).toEqual({ data: [] });
});
it('should return serviceList when not empty', () => {
const state = {
reactReduxRequest: { serviceList: { data: [{ foo: 'bar' }] } },
sorting: { service: {} }
};
expect(getServiceList(state)).toEqual({ data: [{ foo: 'bar' }] });
});
});
describe('ServiceListRequest', () => {
let loadSpy;
let renderSpy;
let wrapper;
beforeEach(() => {
const state = {
reactReduxRequest: {
serviceList: { status: 'my-status', data: [{ foo: 'bar' }] }
},
sorting: { service: {} }
};
loadSpy = jest.spyOn(rest, 'loadServiceList').mockReturnValue();
renderSpy = jest.fn().mockReturnValue(<div>rendered</div>);
wrapper = mountWithStore(
<ServiceListRequest
urlParams={{ start: 'myStart', end: 'myEnd' }}
render={renderSpy}
/>,
state
);
});
it('should call render method', () => {
expect(renderSpy).toHaveBeenCalledWith({
data: [{ foo: 'bar' }],
status: 'my-status'
});
});
it('should call "loadServiceList"', () => {
expect(loadSpy).toHaveBeenCalledWith({
start: 'myStart',
end: 'myEnd'
});
});
it('should render component', () => {
expect(wrapper.html()).toEqual('<div>rendered</div>');
});
});
});

View file

@ -1,49 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { ErrorDistributionAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/distribution/get_distribution';
import { loadErrorDistribution } from '../../services/rest/apm/error_groups';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'errorDistribution';
const INITIAL_DATA: ErrorDistributionAPIResponse = {
buckets: [],
totalHits: 0,
bucketSize: 0
};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorDistribution(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorDistributionRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ErrorDistributionAPIResponse>;
}) {
const { serviceName, start, end, errorGroupId, kuery } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadErrorDistribution}
args={[{ serviceName, start, end, errorGroupId, kuery }]}
selector={getErrorDistribution}
render={render}
/>
);
}

View file

@ -1,45 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { ErrorGroupAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_group';
import { loadErrorGroupDetails } from '../../services/rest/apm/error_groups';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'errorGroupDetails';
const INITIAL_DATA: ErrorGroupAPIResponse = { occurrencesCount: 0 };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorGroupDetails(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorGroupDetailsRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ErrorGroupAPIResponse>;
}) {
const { serviceName, errorGroupId, start, end, kuery } = urlParams;
if (!(serviceName && start && end && errorGroupId)) {
return null;
}
return (
<Request
id={ID}
fn={loadErrorGroupDetails}
args={[{ serviceName, start, end, errorGroupId, kuery }]}
selector={getErrorGroupDetails}
render={render}
/>
);
}

View file

@ -1,52 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { ErrorGroupListAPIResponse } from 'x-pack/plugins/apm/server/lib/errors/get_error_groups';
import { loadErrorGroupList } from '../../services/rest/apm/error_groups';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'errorGroupList';
const INITIAL_DATA: ErrorGroupListAPIResponse = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getErrorGroupList(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function ErrorGroupOverviewRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ErrorGroupListAPIResponse>;
}) {
const {
serviceName,
start,
end,
sortField,
sortDirection,
kuery
} = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadErrorGroupList}
args={[{ serviceName, start, end, sortField, sortDirection, kuery }]}
selector={getErrorGroupList}
render={render}
/>
);
}

View file

@ -1,18 +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 { get } from 'lodash';
import { createSelector } from 'reselect';
export function createInitialDataSelector<T>(initialData: T) {
return createSelector(
state => state,
state => {
const data: T = get(state, 'data') || initialData;
return { ...state, data };
}
);
}

View file

@ -1,35 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { LicenseApiResponse, loadLicense } from '../../services/rest/xpack';
import { IReduxState } from '../rootReducer';
import { createInitialDataSelector } from './helpers';
const ID = 'license';
const INITIAL_DATA = {
features: {
watcher: { is_available: false },
ml: { is_available: false }
},
license: { is_active: false }
};
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getLicense(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function LicenceRequest({
render
}: {
render: RRRRender<LicenseApiResponse>;
}) {
return (
<Request id={ID} fn={loadLicense} selector={getLicense} render={render} />
);
}

View file

@ -1,39 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { getMLJob, MLJobApiResponse } from '../../services/rest/ml';
import { IReduxState } from '../rootReducer';
import { createInitialDataSelector } from './helpers';
const INITIAL_DATA = { count: 0, jobs: [] };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
const ID = 'MLJobs';
function selectMlJobs(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function MLJobsRequest({
serviceName,
transactionType = '*',
render
}: {
serviceName: string;
transactionType?: string;
render: RRRRender<MLJobApiResponse>;
}) {
return (
<Request
id={ID}
fn={getMLJob}
args={[{ serviceName, transactionType }]}
render={render}
selector={selectMlJobs}
/>
);
}

View file

@ -1,51 +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 { first, get } from 'lodash';
import React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { IUrlParams } from 'x-pack/plugins/apm/public/store/urlParams';
import { ServiceAPIResponse } from 'x-pack/plugins/apm/server/lib/services/get_service';
import { loadServiceDetails } from '../../services/rest/apm/services';
import { IReduxState } from '../rootReducer';
import { createInitialDataSelector } from './helpers';
const ID = 'serviceDetails';
const INITIAL_DATA = { types: [] };
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export function getServiceDetails(state: IReduxState) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function getDefaultTransactionType(state: IReduxState) {
const types: string[] = get(state.reactReduxRequest[ID], 'data.types');
return first(types);
}
export function ServiceDetailsRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ServiceAPIResponse>;
}) {
const { serviceName, start, end, kuery } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadServiceDetails}
args={[{ serviceName, start, end, kuery }]}
selector={getServiceDetails}
render={render}
/>
);
}

View file

@ -1,49 +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 React from 'react';
import { Request, RRRRender, RRRRenderResponse } from 'react-redux-request';
import { ServiceListAPIResponse } from 'x-pack/plugins/apm/server/lib/services/get_services';
import { loadServiceList } from '../../services/rest/apm/services';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'serviceList';
const INITIAL_DATA: ServiceListAPIResponse = [];
const withInitialData = createInitialDataSelector<ServiceListAPIResponse>(
INITIAL_DATA
);
export function getServiceList(
state: IReduxState
): RRRRenderResponse<ServiceListAPIResponse> {
return withInitialData(state.reactReduxRequest[ID]);
}
export function ServiceListRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ServiceListAPIResponse>;
}) {
const { start, end, kuery } = urlParams;
if (!(start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadServiceList}
args={[{ start, end, kuery }]}
selector={getServiceList}
render={render}
/>
);
}

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 React from 'react';
import { Request, RRRRender, RRRRenderResponse } from 'react-redux-request';
import { createSelector } from 'reselect';
import { loadMetricsChartDataForService } from 'x-pack/plugins/apm/public/services/rest/apm/metrics';
import { IMemoryChartData } from 'x-pack/plugins/apm/public/store/selectors/chartSelectors';
import { MetricsChartAPIResponse } from 'x-pack/plugins/apm/server/lib/metrics/get_all_metrics_chart_data';
import { IReduxState } from '../rootReducer';
import { getCPUSeries, getMemorySeries } from '../selectors/chartSelectors';
import { getUrlParams, IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'metricsChartData';
const INITIAL_DATA: MetricsChartAPIResponse = {
memory: {
series: {
memoryUsedAvg: [],
memoryUsedMax: []
},
overallValues: {
memoryUsedAvg: null,
memoryUsedMax: null
},
totalHits: 0
},
cpu: {
series: {
systemCPUAverage: [],
systemCPUMax: [],
processCPUAverage: [],
processCPUMax: []
},
overallValues: {
systemCPUAverage: null,
systemCPUMax: null,
processCPUAverage: null,
processCPUMax: null
},
totalHits: 0
}
};
type MetricsChartDataSelector = (
state: IReduxState
) => RRRRenderResponse<MetricsChartAPIResponse>;
const withInitialData = createInitialDataSelector<MetricsChartAPIResponse>(
INITIAL_DATA
);
const selectMetricsChartData: MetricsChartDataSelector = state =>
withInitialData(state.reactReduxRequest[ID]);
export const selectTransformedMetricsChartData = createSelector(
[getUrlParams, selectMetricsChartData],
(urlParams, response) => ({
...response,
data: {
...response.data,
memory: getMemorySeries(urlParams, response.data.memory),
cpu: getCPUSeries(response.data.cpu)
}
})
);
interface Props {
urlParams: IUrlParams;
render: RRRRender<IMemoryChartData>;
}
export function MetricsChartDataRequest({ urlParams, render }: Props) {
const { serviceName, start, end } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadMetricsChartDataForService}
args={[urlParams]}
selector={selectTransformedMetricsChartData}
render={render}
/>
);
}

View file

@ -1,56 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { createSelector } from 'reselect';
import { TraceListAPIResponse } from 'x-pack/plugins/apm/server/lib/traces/get_top_traces';
import { loadTraceList } from '../../services/rest/apm/traces';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'traceList';
const INITIAL_DATA: TraceListAPIResponse = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
const selectRRR = (state = {} as IReduxState) => state.reactReduxRequest;
export const selectTraceList = createSelector(
[selectRRR],
reactReduxRequest => {
return withInitialData(reactReduxRequest[ID]);
}
);
interface Props {
urlParams: IUrlParams;
render: RRRRender<TraceListAPIResponse>;
}
export function TraceListRequest({ urlParams, render }: Props) {
const { start, end, kuery } = urlParams;
if (!start || !end) {
return null;
}
return (
<Request
id={ID}
fn={loadTraceList}
args={[
{
start,
end,
kuery
}
]}
selector={selectTraceList}
render={render}
/>
);
}

View file

@ -1,73 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { createSelector } from 'reselect';
import { ITransactionChartData } from 'x-pack/plugins/apm/public/store/selectors/chartSelectors';
import { TimeSeriesAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/charts';
import { loadDetailsCharts } from '../../services/rest/apm/transaction_groups';
import { IReduxState } from '../rootReducer';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { getUrlParams, IUrlParams } from '../urlParams';
const ID = 'transactionDetailsCharts';
const INITIAL_DATA: TimeSeriesAPIResponse = {
apmTimeseries: {
totalHits: 0,
responseTimes: {
avg: [],
p95: [],
p99: []
},
tpmBuckets: [],
overallAvgDuration: undefined
},
anomalyTimeseries: undefined
};
export const getTransactionDetailsCharts = createSelector(
getUrlParams,
(state: IReduxState) => state.reactReduxRequest[ID],
(urlParams, detailCharts = {}) => {
return {
...detailCharts,
data: getTransactionCharts(urlParams, detailCharts.data || INITIAL_DATA)
};
}
);
interface Props {
urlParams: IUrlParams;
render: RRRRender<ITransactionChartData>;
}
export function TransactionDetailsChartsRequest({ urlParams, render }: Props) {
const {
serviceName,
start,
end,
transactionType,
transactionName,
kuery
} = urlParams;
if (!(serviceName && start && end && transactionType && transactionName)) {
return null;
}
return (
<Request
id={ID}
fn={loadDetailsCharts}
args={[
{ serviceName, start, end, transactionType, transactionName, kuery }
]}
selector={getTransactionDetailsCharts}
render={render}
/>
);
}

View file

@ -1,81 +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 React from 'react';
import { Request, RRRRender, RRRRenderResponse } from 'react-redux-request';
import { createSelector } from 'reselect';
import { ITransactionDistributionAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/distribution';
import { loadTransactionDistribution } from '../../services/rest/apm/transaction_groups';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'transactionDistribution';
const INITIAL_DATA = { buckets: [], totalHits: 0, bucketSize: 0 };
const withInitialData = createInitialDataSelector<
ITransactionDistributionAPIResponse
>(INITIAL_DATA);
export function getTransactionDistribution(
state: IReduxState
): RRRRenderResponse<ITransactionDistributionAPIResponse> {
return withInitialData(state.reactReduxRequest[ID]);
}
export const getDefaultDistributionSample = createSelector(
getTransactionDistribution,
distribution => {
const { defaultSample = {} } = distribution.data;
return {
traceId: defaultSample.traceId,
transactionId: defaultSample.transactionId
};
}
);
export function TransactionDistributionRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<ITransactionDistributionAPIResponse>;
}) {
const {
serviceName,
transactionType,
transactionId,
traceId,
start,
end,
transactionName,
kuery
} = urlParams;
if (!(serviceName && transactionType && start && end && transactionName)) {
return null;
}
return (
<Request
id={ID}
fn={loadTransactionDistribution}
args={[
{
serviceName,
transactionType,
transactionId,
traceId,
start,
end,
transactionName,
kuery
}
]}
selector={getTransactionDistribution}
render={render}
/>
);
}

View file

@ -1,85 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { createSelector } from 'reselect';
import { TransactionListAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/get_top_transactions';
import { loadTransactionList } from '../../services/rest/apm/transaction_groups';
import { IReduxState } from '../rootReducer';
import { IUrlParams } from '../urlParams';
import { createInitialDataSelector } from './helpers';
const ID = 'transactionList';
const INITIAL_DATA: TransactionListAPIResponse = [];
const withInitialData = createInitialDataSelector<TransactionListAPIResponse>(
INITIAL_DATA
);
const getRelativeImpact = (
impact: number,
impactMin: number,
impactMax: number
) =>
Math.max(
((impact - impactMin) / Math.max(impactMax - impactMin, 1)) * 100,
1
);
function getWithRelativeImpact(items: TransactionListAPIResponse) {
const impacts = items.map(({ impact }) => impact);
const impactMin = Math.min(...impacts);
const impactMax = Math.max(...impacts);
return items.map(item => {
return {
...item,
impactRelative: getRelativeImpact(item.impact, impactMin, impactMax)
};
});
}
export const getTransactionList = createSelector(
(state: IReduxState) => withInitialData(state.reactReduxRequest[ID]),
transactionList => {
return {
...transactionList,
data: getWithRelativeImpact(transactionList.data)
};
}
);
export function TransactionListRequest({
urlParams,
render
}: {
urlParams: IUrlParams;
render: RRRRender<TransactionListAPIResponse>;
}) {
const { serviceName, start, end, transactionType, kuery } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadTransactionList}
args={[
{
serviceName,
start,
end,
transactionType,
kuery
}
]}
selector={getTransactionList}
render={render}
/>
);
}

View file

@ -1,97 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { get } from 'lodash';
import React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { createSelector } from 'reselect';
import { ITransactionChartData } from 'x-pack/plugins/apm/public/store/selectors/chartSelectors';
import { TimeSeriesAPIResponse } from 'x-pack/plugins/apm/server/lib/transactions/charts';
import {
loadOverviewCharts,
loadOverviewChartsForAllTypes
} from '../../services/rest/apm/transaction_groups';
import { IReduxState } from '../rootReducer';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { getUrlParams, IUrlParams } from '../urlParams';
const ID = 'transactionOverviewCharts';
const INITIAL_DATA: TimeSeriesAPIResponse = {
apmTimeseries: {
totalHits: 0,
responseTimes: {
avg: [],
p95: [],
p99: []
},
tpmBuckets: [],
overallAvgDuration: undefined
},
anomalyTimeseries: undefined
};
const selectChartData = (state: IReduxState) => state.reactReduxRequest[ID];
export const getTransactionOverviewCharts = createSelector(
[getUrlParams, selectChartData],
(urlParams, overviewCharts = {}) => {
return {
...overviewCharts,
data: getTransactionCharts(urlParams, overviewCharts.data || INITIAL_DATA)
};
}
);
export const selectHasMLJob = createSelector(
[selectChartData],
chartData => get(chartData, 'data.anomalyTimeseries') !== undefined
);
interface Props {
urlParams: IUrlParams;
render: RRRRender<ITransactionChartData>;
}
export function TransactionOverviewChartsRequest({ urlParams, render }: Props) {
const { serviceName, start, end, transactionType, kuery } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadOverviewCharts}
args={[{ serviceName, start, end, transactionType, kuery }]}
selector={getTransactionOverviewCharts}
render={render}
/>
);
}
// Ignores transaction type from urlParams and requests charts
// for ALL transaction types within this service
export function TransactionOverviewChartsRequestForAllTypes({
urlParams,
render
}: Props) {
const { serviceName, start, end, kuery } = urlParams;
if (!(serviceName && start && end)) {
return null;
}
return (
<Request
id={ID}
fn={loadOverviewChartsForAllTypes}
args={[{ serviceName, start, end, kuery }]}
selector={getTransactionOverviewCharts}
render={render}
/>
);
}

View file

@ -1,51 +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 React from 'react';
import { Request, RRRRender } from 'react-redux-request';
import { TraceAPIResponse } from 'x-pack/plugins/apm/server/lib/traces/get_trace';
import {
getWaterfall,
IWaterfall
} from '../../components/app/TransactionDetails/Transaction/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';
import { loadTrace } from '../../services/rest/apm/traces';
import { IUrlParams } from '../urlParams';
export const ID = 'waterfall';
interface Props {
urlParams: IUrlParams;
traceId?: string;
render: RRRRender<IWaterfall>;
}
export function WaterfallRequest({ urlParams, render, traceId }: Props) {
const { start, end } = urlParams;
if (!(traceId && start && end)) {
return null;
}
return (
<Request<TraceAPIResponse>
id={ID}
fn={loadTrace}
args={[{ traceId, start, end }]}
render={({
args,
data = { trace: [], errorsPerTransaction: {} },
status
}) => {
const waterfall = getWaterfall(
data.trace,
data.errorsPerTransaction,
urlParams.transactionId
);
return render({ args, data: waterfall, status });
}}
/>
);
}

View file

@ -5,20 +5,16 @@
*/
import { Location } from 'history';
import { reducer } from 'react-redux-request';
import { combineReducers } from 'redux';
import { StringMap } from '../../typings/common';
import { locationReducer } from './location';
import { IUrlParams, urlParamsReducer } from './urlParams';
export interface IReduxState {
location: Location;
urlParams: IUrlParams;
reactReduxRequest: StringMap<any>;
}
export const rootReducer = combineReducers({
location: locationReducer,
urlParams: urlParamsReducer,
reactReduxRequest: reducer
urlParams: urlParamsReducer
});

View file

@ -65,13 +65,27 @@ export interface ITransactionChartData {
noHits: boolean;
tpmSeries: ITpmBucket[] | IEmptySeries[];
responseTimeSeries: TimeSerie[] | IEmptySeries[];
hasMLJob: boolean;
}
const INITIAL_DATA = {
apmTimeseries: {
totalHits: 0,
responseTimes: {
avg: [],
p95: [],
p99: []
},
tpmBuckets: [],
overallAvgDuration: undefined
},
anomalyTimeseries: undefined
};
export function getTransactionCharts(
urlParams: IUrlParams,
timeseriesResponse: TimeSeriesAPIResponse
) {
const { start, end, transactionType } = urlParams;
{ start, end, transactionType }: IUrlParams,
timeseriesResponse: TimeSeriesAPIResponse = INITIAL_DATA
): ITransactionChartData {
const { apmTimeseries, anomalyTimeseries } = timeseriesResponse;
const noHits = apmTimeseries.totalHits === 0;
const tpmSeries = noHits
@ -82,26 +96,22 @@ export function getTransactionCharts(
? getEmptySerie(start, end)
: getResponseTimeSeries(apmTimeseries, anomalyTimeseries);
const chartsResult: ITransactionChartData = {
return {
noHits,
tpmSeries,
responseTimeSeries
responseTimeSeries,
hasMLJob: timeseriesResponse.anomalyTimeseries !== undefined
};
return chartsResult;
}
export interface IMemoryChartData extends MetricsChartAPIResponse {
series: TimeSerie[] | IEmptySeries[];
}
export type MemoryMetricSeries = ReturnType<typeof getMemorySeries>;
export function getMemorySeries(
urlParams: IUrlParams,
{ start, end }: IUrlParams,
memoryChartResponse: MetricsChartAPIResponse['memory']
) {
const { start, end } = urlParams;
const { series, overallValues, totalHits } = memoryChartResponse;
const seriesList: IMemoryChartData['series'] =
const seriesList =
totalHits === 0
? getEmptySerie(start, end)
: [
@ -132,14 +142,12 @@ export function getMemorySeries(
];
return {
...memoryChartResponse,
totalHits: memoryChartResponse.totalHits,
series: seriesList
};
}
export interface ICPUChartData extends MetricsChartAPIResponse {
series: TimeSerie[];
}
export type CPUMetricSeries = ReturnType<typeof getCPUSeries>;
export function getCPUSeries(CPUChartResponse: MetricsChartAPIResponse['cpu']) {
const { series, overallValues } = CPUChartResponse;
@ -183,7 +191,7 @@ export function getCPUSeries(CPUChartResponse: MetricsChartAPIResponse['cpu']) {
}
];
return { ...CPUChartResponse, series: seriesList };
return { totalHits: CPUChartResponse.totalHits, series: seriesList };
}
interface TimeSerie {

View file

@ -1,17 +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 { createSelector } from 'reselect';
import { getLicense } from 'x-pack/plugins/apm/public/store/reactReduxRequest/license';
export const selectIsMLAvailable = createSelector(
[getLicense],
license =>
license.data &&
license.data.features &&
license.data.features.ml &&
license.data.features.ml.is_available
);

View file

@ -7,14 +7,11 @@
import datemath from '@elastic/datemath';
import { Location } from 'history';
import { compact, pick } from 'lodash';
import { createSelector } from 'reselect';
import {
legacyDecodeURIComponent,
toQuery
} from '../components/shared/Links/url_helpers';
import { LOCATION_UPDATE } from './location';
import { getDefaultTransactionType } from './reactReduxRequest/serviceDetails';
import { getDefaultDistributionSample } from './reactReduxRequest/transactionDistribution';
import { IReduxState } from './rootReducer';
// ACTION TYPES
@ -153,16 +150,11 @@ export function toNumber(value?: string) {
}
}
function toString(str?: string | string[]) {
if (
str === '' ||
str === 'null' ||
str === 'undefined' ||
Array.isArray(str)
) {
function toString(value?: string) {
if (value === '' || value === 'null' || value === 'undefined') {
return;
}
return str;
return value;
}
export function toBoolean(value?: string) {
@ -211,23 +203,9 @@ export function refreshTimeRange(time: TimeRange): TimeRangeRefreshAction {
}
// Selectors
export const getUrlParams = createSelector(
(state: IReduxState) => state.urlParams,
getDefaultTransactionType,
getDefaultDistributionSample,
(
urlParams,
transactionType: string,
{ traceId, transactionId }
): IUrlParams => {
return {
transactionType,
transactionId,
traceId,
...urlParams
};
}
);
export function getUrlParams(state: IReduxState) {
return state.urlParams;
}
export interface IUrlParams {
detailTab?: string;

View file

@ -8,11 +8,9 @@
import { mount, ReactWrapper } from 'enzyme';
import enzymeToJson from 'enzyme-to-json';
import createHistory from 'history/createHashHistory';
import 'jest-styled-components';
import moment from 'moment';
import { Moment } from 'moment-timezone';
import PropTypes from 'prop-types';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
@ -29,51 +27,6 @@ export function toJson(wrapper: ReactWrapper) {
});
}
const defaultRoute = {
match: { path: '/', url: '/', params: {}, isExact: true },
location: { pathname: '/', search: '', hash: '', key: '4yyjf5' }
};
export function mountWithRouterAndStore(
Component: React.ReactElement,
storeState = {},
route = defaultRoute
) {
const store = createMockStore(storeState);
const history = createHistory();
const options = {
context: {
store,
router: {
history,
route
}
},
childContextTypes: {
store: PropTypes.object.isRequired,
router: PropTypes.object.isRequired
}
};
return mount(Component, options);
}
export function mountWithStore(Component: React.ReactElement, storeState = {}) {
const store = createMockStore(storeState);
const options = {
context: {
store
},
childContextTypes: {
store: PropTypes.object.isRequired
}
};
return mount(Component, options);
}
export function mockMoment() {
// avoid timezone issues
jest
@ -90,11 +43,6 @@ export function mockMoment() {
});
}
// Await this when you need to "flush" promises to immediately resolve or throw in tests
export async function asyncFlush() {
return new Promise(resolve => setTimeout(resolve, 0));
}
// Useful for getting the rendered href from any kind of link component
export async function getRenderedHref(
Component: React.FunctionComponent<{}>,
@ -109,7 +57,7 @@ export async function getRenderedHref(
</Provider>
);
await asyncFlush();
await tick();
return mounted.render().attr('href');
}
@ -118,3 +66,10 @@ export function mockNow(date: string) {
const fakeNow = new Date(date).getTime();
return jest.spyOn(Date, 'now').mockReturnValue(fakeNow);
}
export function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Await this when you need to "flush" promises to immediately resolve or throw in tests
export const tick = () => new Promise(resolve => setImmediate(resolve, 0));

View file

@ -1,36 +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.
*/
// Everything in here should be moved to http://github.com/sqren/react-redux-request
declare module 'react-redux-request' {
import React from 'react';
// status and args are optional, especially for places that use initial data in a reducer
export interface RRRRenderResponse<T, P = any[]> {
status?: 'SUCCESS' | 'LOADING' | 'FAILURE';
data: T;
args?: P;
}
export type RRRRender<T, P = any[]> = (
res: RRRRenderResponse<T, P>
) => React.ReactNode;
export interface RequestProps<T, P> {
id: string;
fn: (args: any) => Promise<any>;
selector?: (state: any) => any;
args?: any[];
render?: RRRRender<T, P>;
}
export function reducer(state: any): any;
export class Request<T, P = any[]> extends React.Component<
RequestProps<T, P>
> {}
}

116
yarn.lock
View file

@ -772,13 +772,20 @@
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.3.4":
"@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4":
version "7.3.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.3.4.tgz#73d12ba819e365fcf7fd152aed56d6df97d21c83"
integrity sha512-IvfvnMdSaLBateu0jfsYIpZTxAc2cKEXEMiezGGN75QcBcecDUKd3PgLAncT0oOgxKy8dd8hrJKj9MfzgfZd6g==
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.4.2":
version "7.4.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.2.tgz#f5ab6897320f16decd855eed70b705908a313fe8"
integrity sha512-7Bl2rALb7HpvXFL7TETNzKSAeBVCPHELzc0C//9FCxN8nsiueWSJBqaF+2oIJScyILStASR/Cx5WMkXGYTiJFA==
dependencies:
regenerator-runtime "^0.13.2"
"@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.1.2", "@babel/template@^7.2.2":
version "7.2.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.2.2.tgz#005b3fdf0ed96e88041330379e0da9a708eb2907"
@ -1057,6 +1064,14 @@
normalize-path "^2.0.1"
through2 "^2.0.3"
"@jest/types@^24.5.0":
version "24.5.0"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-24.5.0.tgz#feee214a4d0167b0ca447284e95a57aa10b3ee95"
integrity sha512-kN7RFzNMf2R8UDadPOl6ReyI+MT8xfqRuAnuVL+i4gwjv/zubdDK+EDeLHYwq1j0CSSR2W/MmgaRlMZJzXdmVA==
dependencies:
"@types/istanbul-lib-coverage" "^1.1.0"
"@types/yargs" "^12.0.9"
"@mapbox/geojson-area@0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10"
@ -1143,6 +1158,11 @@
dependencies:
url-pattern "^1.0.3"
"@sheerun/mutationobserver-shim@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b"
integrity sha512-vTCdPp/T/Q3oSqwHmZ5Kpa9oI7iLtGl3RQaA/NyLHikvcrPxACkkKVr/XzkSPJWXHRhKGzVvb0urJsbMlRxi1Q==
"@sindresorhus/is@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
@ -1946,6 +1966,11 @@
dependencies:
"@types/node" "*"
"@types/istanbul-lib-coverage@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.0.tgz#2cc2ca41051498382b43157c8227fea60363f94a"
integrity sha512-ohkhb9LehJy+PA40rDtGAji61NCgdtKLAlFoYp4cnuuQEswwdK3vz9SOIkkyc3wrk8dzjphQApNs56yyXLStaQ==
"@types/jest-diff@*":
version "20.0.1"
resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89"
@ -2046,6 +2071,11 @@
resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8"
integrity sha512-TzzIZihV+y9kxSg5xJMkyIkaoGkXi50isZTtGHObNHRqAAwjGNjSCNPI7AUAv0tZUKTq9f2cdkCUd/2JVZUTrA==
"@types/lru-cache@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
"@types/mime-db@*":
version "1.27.0"
resolved "https://registry.yarnpkg.com/@types/mime-db/-/mime-db-1.27.0.tgz#9bc014a1fd1fdf47649c1a54c6dd7966b8284792"
@ -2114,6 +2144,11 @@
resolved "https://registry.yarnpkg.com/@types/numeral/-/numeral-0.0.25.tgz#b6f55062827a4787fe4ab151cf3412a468e65271"
integrity sha512-ShHzHkYD+Ldw3eyttptCpUhF1/mkInWwasQkCNXZHOsJMJ/UMa8wXrxSrTJaVk0r4pLK/VnESVM0wFsfQzNEKQ==
"@types/object-hash@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/object-hash/-/object-hash-1.2.0.tgz#d65904331bd0b05c7d5ece75f9ddfdbe82affd30"
integrity sha512-0JKYQRatHdzijO/ni7JV5eHUJWaMRpGvwiABk8U5iAk5Corm0yLNEfYGNkZWYc+wCyCKKpg0+TsZIvP8AymIYA==
"@types/opn@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/opn/-/opn-5.1.0.tgz#bff7bc371677f4bdbb37884400e03fd81f743927"
@ -2421,6 +2456,11 @@
"@types/events" "*"
"@types/node" "*"
"@types/yargs@^12.0.9":
version "12.0.10"
resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.10.tgz#17a8ec65cd8e88f51b418ceb271af18d3137df67"
integrity sha512-WsVzTPshvCSbHThUduGGxbmnwcpkgSctHGHTqzWyFg4lYAuV5qXlyFPOsP3OWqCINfmg/8VXP+zJaa4OxEsBQQ==
"@types/zen-observable@^0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d"
@ -7686,6 +7726,26 @@ dom-serializer@0, dom-serializer@~0.1.0:
domelementtype "^1.3.0"
entities "^1.1.1"
dom-testing-library@^3.13.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-3.17.1.tgz#3291bc3cf68c555ba5e663697ee77d604aaa122b"
integrity sha512-SbkaRfQvuLjnv+xFgSo/cmKoN9tjBL6Rh1f3nQH9jnjUe5q+keRwacYSi3uSpcB4D1K768iavCayKH3ZN9ea+g==
dependencies:
"@babel/runtime" "^7.3.4"
"@sheerun/mutationobserver-shim" "^0.3.2"
pretty-format "^24.0.0"
wait-for-expect "^1.1.0"
dom-testing-library@^3.18.2:
version "3.18.2"
resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-3.18.2.tgz#07d65166743ad3299b7bee5b488e9622c31241bc"
integrity sha512-+nYUgGhHarrCY8kLVmyHlgM+IGwBXXrYsWIJB6vpAx2ne9WFgKfwMGcOkkTKQhuAro0sP6RIuRGfm5NF3+ccmQ==
dependencies:
"@babel/runtime" "^7.3.4"
"@sheerun/mutationobserver-shim" "^0.3.2"
pretty-format "^24.5.0"
wait-for-expect "^1.1.0"
dom-walk@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
@ -16389,6 +16449,11 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-hash@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-1.3.1.tgz#fde452098a951cb145f039bb7d455449ddc126df"
integrity sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==
object-inspect@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.6.0.tgz#c70b6cbf72f274aab4c34c0c82f5167bf82cf15b"
@ -17672,6 +17737,16 @@ pretty-format@^24.0.0:
ansi-regex "^4.0.0"
ansi-styles "^3.2.0"
pretty-format@^24.5.0:
version "24.5.0"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.5.0.tgz#cc69a0281a62cd7242633fc135d6930cd889822d"
integrity sha512-/3RuSghukCf8Riu5Ncve0iI+BzVkbRU5EeUoArKARZobREycuH5O4waxvaNIloEXdb0qwgmEAed5vTpX1HNROQ==
dependencies:
"@jest/types" "^24.5.0"
ansi-regex "^4.0.0"
ansi-styles "^3.2.0"
react-is "^16.8.4"
pretty-hrtime@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@ -18483,6 +18558,14 @@ react-grid-layout@^0.16.2:
react-draggable "3.x"
react-resizable "1.x"
react-hooks-testing-library@^0.3.8:
version "0.3.8"
resolved "https://registry.yarnpkg.com/react-hooks-testing-library/-/react-hooks-testing-library-0.3.8.tgz#717595ed7be500023963dd502f188aa932bf70f0"
integrity sha512-YFnyd2jH2voikSBGufqhprnxMTHgosOHlO5EXhuQycWxfeTCIiw/17aiYbpvRRDRB/0j8QvI/jHXMNVBKw7WzA==
dependencies:
"@babel/runtime" "^7.4.2"
react-testing-library "^6.0.2"
react-input-autosize@^2.1.2, react-input-autosize@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
@ -18524,6 +18607,11 @@ react-is@^16.8.1, react-is@^16.8.2:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.2.tgz#09891d324cad1cb0c1f2d91f70a71a4bee34df0f"
integrity sha512-D+NxhSR2HUCjYky1q1DwpNUD44cDpUXzSmmFyC3ug1bClcU/iDNy0YNn1iwme28fn+NFhpA13IndOd42CrFb+Q==
react-is@^16.8.4:
version "16.8.5"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.5.tgz#c54ac229dd66b5afe0de5acbe47647c3da692ff8"
integrity sha512-sudt2uq5P/2TznPV4Wtdi+Lnq3yaYW8LfvPKLM9BKD8jJNBkxMVyB0C9/GmVhLw7Jbdmndk/73n7XQGeN9A3QQ==
react-is@~16.3.0:
version "16.3.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.3.2.tgz#f4d3d0e2f5fbb6ac46450641eb2e25bf05d36b22"
@ -18770,6 +18858,22 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.8.0:
react-is "^16.8.2"
scheduler "^0.13.2"
react-testing-library@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-6.0.0.tgz#81edfcfae8a795525f48685be9bf561df45bb35d"
integrity sha512-h0h+YLe4KWptK6HxOMnoNN4ngu3W8isrwDmHjPC5gxc+nOZOCurOvbKVYCvvuAw91jdO7VZSm/5KR7TxKnz0qA==
dependencies:
"@babel/runtime" "^7.3.1"
dom-testing-library "^3.13.1"
react-testing-library@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-6.0.3.tgz#8b5d276a353c17ce4f7486015bb7a1c8827c442c"
integrity sha512-tN0A6nywSOoL8kriqru3rSdw31PxuquL7xnW6xBI0aTNw0VO3kZQtaEa0npUH9dX0MIsSunB0nbElRrc4VtAzw==
dependencies:
"@babel/runtime" "^7.4.2"
dom-testing-library "^3.18.2"
react-textarea-autosize@^7.0.4:
version "7.0.4"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.0.4.tgz#4e4be649b544a88713e7b5043f76950f35d3d503"
@ -19238,6 +19342,11 @@ regenerator-runtime@^0.12.0, regenerator-runtime@^0.12.1:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447"
integrity sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==
regenerator-transform@^0.13.3:
version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.13.3.tgz#264bd9ff38a8ce24b06e0636496b2c856b57bcbb"
@ -23911,6 +24020,11 @@ w3c-hr-time@^1.0.1:
dependencies:
browser-process-hrtime "^0.1.2"
wait-for-expect@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.1.0.tgz#6607375c3f79d32add35cd2c87ce13f351a3d453"
integrity sha512-vQDokqxyMyknfX3luCDn16bSaRcOyH6gGuUXMIbxBLeTo6nWuEWYqMTT9a+44FmW8c2m6TRWBdNvBBjA1hwEKg==
walk@2.3.x:
version "2.3.9"
resolved "https://registry.yarnpkg.com/walk/-/walk-2.3.9.tgz#31b4db6678f2ae01c39ea9fb8725a9031e558a7b"