mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[APM] useFetcher: Replace react-redux-request with hooks and Context API (#33392)
This commit is contained in:
parent
a7062883d2
commit
30c63f57fd
88 changed files with 3122 additions and 2921 deletions
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 >=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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 >=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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>,
|
||||
}
|
||||
`;
|
|
@ -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>,
|
||||
]
|
||||
`;
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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(() => {
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
121
x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx
Normal file
121
x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx
Normal 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]
|
||||
]);
|
||||
});
|
||||
});
|
159
x-pack/plugins/apm/public/hooks/useFetcher.test.tsx
Normal file
159
x-pack/plugins/apm/public/hooks/useFetcher.test.tsx
Normal 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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
83
x-pack/plugins/apm/public/hooks/useFetcher.tsx
Normal file
83
x-pack/plugins/apm/public/hooks/useFetcher.tsx
Normal 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 || {};
|
||||
}
|
85
x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts
Normal file
85
x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
50
x-pack/plugins/apm/public/hooks/useTransactionList.ts
Normal file
50
x-pack/plugins/apm/public/hooks/useTransactionList.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -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
|
||||
};
|
||||
}
|
28
x-pack/plugins/apm/public/hooks/useWaterfall.ts
Normal file
28
x-pack/plugins/apm/public/hooks/useWaterfall.ts
Normal 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 };
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ describe('root reducer', () => {
|
|||
|
||||
expect(state).toEqual({
|
||||
location: { hash: '', pathname: '', search: '' },
|
||||
reactReduxRequest: {},
|
||||
urlParams: {}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
116
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue