[APM] useUrlParams as separate hook + removes redux 🎉 (#34792)

* Exploring a useUrlParams hook separate from useLocation

* Refactors url params hook into context + hook

* Fixes unmount async bugs in some components

* Rewrites datepicker tests

* Fixes some import reference problems

* Moves urlParams logic out of store and into context directory, updates references

* Makes urlParams context update itself on location changes

* Adds url params tests

* Removed left-over debug additions

* Small improvement for types based on review feedback 👍

* Converts url params to hooks for ErrorGroupDetails component

* Converts url params to hooks for ServiceDetails component

* Converts url params to hooks for ServiceOverview component

* Converts url params to hooks for TraceOverview component

* Converts url params to hooks for TransactionDetails component

* Converts url params to hooks for KueryBar component (plus TS convert)

* Cleanup removing redux

* Fixes tests after removing redux

* Updates type reference to use ui/ import

* Moves selectors folder into public and deletes store folder

* Removes unnecessary mountWithRouterAndStore

* Correctly orders imports to satisfy lint gods

* Removes unused imports
This commit is contained in:
Jason Rhodes 2019-04-24 10:00:53 -04:00 committed by GitHub
parent 4f05920f53
commit 1794c8b01c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 927 additions and 1373 deletions

View file

@ -20,10 +20,11 @@ import { first } from 'lodash';
import { idx } from '@kbn/elastic-idx';
import { ErrorGroupAPIResponse } from '../../../../../server/lib/errors/get_error_group';
import { APMError } from '../../../../../typings/es_schemas/ui/APMError';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { px, unit } from '../../../../style/variables';
import { DiscoverErrorLink } from '../../../shared/Links/DiscoverLinks/DiscoverErrorLink';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { history } from '../../../../utils/history';
import { ErrorMetadata } from '../../../shared/MetadataTable/ErrorMetadata';
import { Stacktrace } from '../../../shared/Stacktrace';
import {

View file

@ -1,24 +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 { connect } from 'react-redux';
import { IReduxState } from '../../../store/rootReducer';
import { getUrlParams } from '../../../store/urlParams';
import { ErrorGroupDetailsView } from './view';
function mapStateToProps(state = {} as IReduxState) {
return {
urlParams: getUrlParams(state),
location: state.location
};
}
const mapDispatchToProps = {};
export const ErrorGroupDetails = connect(
mapStateToProps,
mapDispatchToProps
)(ErrorGroupDetailsView);

View file

@ -15,7 +15,6 @@ import {
} from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React, { Fragment } from 'react';
import styled from 'styled-components';
import { idx } from '@kbn/elastic-idx';
@ -25,12 +24,12 @@ import {
loadErrorDistribution,
loadErrorGroupDetails
} from '../../../services/rest/apm/error_groups';
import { IUrlParams } from '../../../store/urlParams';
import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables';
// @ts-ignore
import { FilterBar } from '../../shared/FilterBar';
import { DetailView } from './DetailView';
import { ErrorDistribution } from './Distribution';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
const Titles = styled.div`
margin-bottom: ${px(units.plus)};
@ -61,12 +60,9 @@ function getShortGroupId(errorGroupId?: string) {
return errorGroupId.slice(0, 5);
}
interface Props {
urlParams: IUrlParams;
location: Location;
}
export function ErrorGroupDetailsView({ urlParams, location }: Props) {
export function ErrorGroupDetails() {
const location = useLocation();
const { urlParams } = useUrlParams();
const { serviceName, start, end, errorGroupId } = urlParams;
const { data: errorGroupData } = useFetcher(

View file

@ -6,12 +6,8 @@
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';
// @ts-ignore
import { createMockStore } from 'redux-test-utils';
import { mockMoment, toJson } from '../../../../../utils/testHelpers';
import { ErrorGroupList } from '../index';
import props from './props.json';
@ -38,39 +34,8 @@ describe('ErrorGroupOverview -> List', () => {
});
it('should render with data', () => {
const storeState = { location: {} };
const wrapper = mountWithRouterAndStore(
<ErrorGroupList {...props} />,
storeState
);
const wrapper = mount(<ErrorGroupList {...props} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
export function mountWithRouterAndStore(
Component: React.ReactElement,
storeState = {}
) {
const store = createMockStore(storeState);
const history = createHistory();
const options = {
context: {
store,
router: {
history,
route: {
match: { path: '/', url: '/', params: {}, isExact: true },
location: { pathname: '/', search: '', hash: '', key: '4yyjf5' }
}
}
},
childContextTypes: {
store: PropTypes.object.isRequired,
router: PropTypes.object.isRequired
}
};
return mount(Component, options);
}

View file

@ -13,7 +13,7 @@ import React, { Component } from 'react';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { ErrorGroupListAPIResponse } from '../../../../../server/lib/errors/get_error_groups';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import {
fontFamilyCode,
fontSizes,
@ -22,7 +22,8 @@ import {
unit
} from '../../../../style/variables';
import { APMLink } from '../../../shared/Links/APMLink';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { history } from '../../../../utils/history';
function paginateItems({
items,

View file

@ -19,7 +19,7 @@ import {
loadErrorDistribution,
loadErrorGroupList
} from '../../../services/rest/apm/error_groups';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { ErrorDistribution } from '../ErrorGroupDetails/Distribution';
import { ErrorGroupList } from './List';

View file

@ -20,14 +20,14 @@ const homeTabs: IHistoryTab[] = [
name: i18n.translate('xpack.apm.home.servicesTabLabel', {
defaultMessage: 'Services'
}),
render: props => <ServiceOverview {...props} />
render: () => <ServiceOverview />
},
{
path: '/traces',
name: i18n.translate('xpack.apm.home.tracesTabLabel', {
defaultMessage: 'Traces'
}),
render: props => <TraceOverview {...props} />
render: () => <TraceOverview />
}
];

View file

@ -22,7 +22,7 @@ exports[`Home component should render 1`] = `
</EuiFlexGroup>
<EuiSpacer />
<FilterBar />
<withRouter(HistoryTabsWithoutRouter)
<HistoryTabs
tabs={
Array [
Object {

View file

@ -8,8 +8,6 @@ import React from 'react';
import { Route, Switch } from 'react-router-dom';
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';
@ -27,7 +25,6 @@ export function Main() {
<GlobalFetchIndicator>
<MainContainer data-test-subj="apmMainContainer">
<UpdateBreadcrumbs />
<Route component={ConnectRouterToRedux} />
<Route component={ScrollToTopOnPathChange} />
<LicenseCheck>
<Switch>

View file

@ -8,7 +8,7 @@ import { EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Coordinate } from '../../../../typings/timeseries';
import { CPUMetricSeries } from '../../../store/selectors/chartSelectors';
import { CPUMetricSeries } from '../../../selectors/chartSelectors';
import { asPercent } from '../../../utils/formatters';
// @ts-ignore
import CustomPlot from '../../shared/charts/CustomPlot';

View file

@ -8,7 +8,7 @@ import { EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { Coordinate } from '../../../../typings/timeseries';
import { MemoryMetricSeries } from '../../../store/selectors/chartSelectors';
import { MemoryMetricSeries } from '../../../selectors/chartSelectors';
import { asPercent } from '../../../utils/formatters';
// @ts-ignore
import CustomPlot from '../../shared/charts/CustomPlot';

View file

@ -5,61 +5,62 @@
*/
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { HistoryTabs } from '../../shared/HistoryTabs';
import { ErrorGroupOverview } from '../ErrorGroupOverview';
import { TransactionOverview } from '../TransactionOverview';
import { ServiceMetrics } from './ServiceMetrics';
import { useLocation } from '../../../hooks/useLocation';
interface TabsProps {
interface Props {
transactionTypes: string[];
urlParams: IUrlParams;
location: Location;
isRumAgent?: boolean;
}
export class ServiceDetailTabs extends React.Component<TabsProps> {
public render() {
const { transactionTypes, urlParams, location, isRumAgent } = this.props;
const { serviceName } = urlParams;
const headTransactionType = transactionTypes[0];
const transactionsTab = {
name: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', {
defaultMessage: 'Transactions'
}),
path: headTransactionType
? `/${serviceName}/transactions/${headTransactionType}`
: `/${serviceName}/transactions`,
routePath: `/${serviceName}/transactions/:transactionType?`,
render: () => (
<TransactionOverview
urlParams={urlParams}
serviceTransactionTypes={transactionTypes}
/>
)
};
const errorsTab = {
name: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', {
defaultMessage: 'Errors'
}),
path: `/${serviceName}/errors`,
render: () => {
return <ErrorGroupOverview urlParams={urlParams} location={location} />;
}
};
const metricsTab = {
name: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', {
defaultMessage: 'Metrics'
}),
path: `/${serviceName}/metrics`,
render: () => <ServiceMetrics urlParams={urlParams} location={location} />
};
const tabs = isRumAgent
? [transactionsTab, errorsTab]
: [transactionsTab, errorsTab, metricsTab];
export function ServiceDetailTabs({
transactionTypes,
urlParams,
isRumAgent
}: Props) {
const location = useLocation();
const { serviceName } = urlParams;
const headTransactionType = transactionTypes[0];
const transactionsTab = {
name: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', {
defaultMessage: 'Transactions'
}),
path: headTransactionType
? `/${serviceName}/transactions/${headTransactionType}`
: `/${serviceName}/transactions`,
routePath: `/${serviceName}/transactions/:transactionType?`,
render: () => (
<TransactionOverview
urlParams={urlParams}
serviceTransactionTypes={transactionTypes}
/>
)
};
const errorsTab = {
name: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', {
defaultMessage: 'Errors'
}),
path: `/${serviceName}/errors`,
render: () => {
return <ErrorGroupOverview urlParams={urlParams} location={location} />;
}
};
const metricsTab = {
name: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', {
defaultMessage: 'Metrics'
}),
path: `/${serviceName}/metrics`,
render: () => <ServiceMetrics urlParams={urlParams} location={location} />
};
const tabs = isRumAgent
? [transactionsTab, errorsTab]
: [transactionsTab, errorsTab, metricsTab];
return <HistoryTabs tabs={tabs} />;
}
return <HistoryTabs tabs={tabs} />;
}

View file

@ -9,7 +9,7 @@ import React, { Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { startMLJob } from '../../../../../services/rest/ml';
import { getAPMIndexPattern } from '../../../../../services/rest/savedObjects';
import { IUrlParams } from '../../../../../store/urlParams';
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink';
import { MachineLearningFlyoutView } from './view';
@ -30,19 +30,26 @@ export class MachineLearningFlyout extends Component<Props, State> {
isCreatingJob: false,
hasIndexPattern: false
};
public willUnmount = false;
public mounted = false;
public componentWillUnmount() {
this.willUnmount = true;
this.mounted = false;
}
public async componentDidMount() {
this.mounted = true;
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 });
}
// setTimeout:0 hack forces the state update to wait for next tick
// in case the component is mid-unmount :/
setTimeout(() => {
if (!this.mounted) {
return;
}
this.setState({
hasIndexPattern: !!indexPattern
});
}, 0);
}
public onClickCreate = async () => {

View file

@ -32,7 +32,7 @@ import React, { Component } from 'react';
import styled from 'styled-components';
import chrome from 'ui/chrome';
import { toastNotifications } from 'ui/notify';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { KibanaLink } from '../../../shared/Links/KibanaLink';
import { createErrorGroupWatch, Schedule } from './createErrorGroupWatch';
import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink';

View file

@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n';
import { memoize } from 'lodash';
import React, { Fragment } from 'react';
import chrome from 'ui/chrome';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { LicenseContext } from '../../Main/LicenseCheck';
import { MachineLearningFlyout } from './MachineLearningFlyout';
import { WatcherFlyout } from './WatcherFlyout';

View file

@ -18,7 +18,7 @@ import { useFetcher } from '../../../hooks/useFetcher';
import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts';
import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts';
import { loadErrorDistribution } from '../../../services/rest/apm/error_groups';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { SyncChartGroup } from '../../shared/charts/SyncChartGroup';
import { TransactionCharts } from '../../shared/charts/TransactionCharts';
import { ErrorDistribution } from '../ErrorGroupDetails/Distribution';

View file

@ -4,17 +4,55 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { IReduxState } from '../../../store/rootReducer';
import { getUrlParams } from '../../../store/urlParams';
import { ServiceDetailsView } from './view';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import React from 'react';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceDetails } from '../../../services/rest/apm/services';
import { FilterBar } from '../../shared/FilterBar';
import { ServiceDetailTabs } from './ServiceDetailTabs';
import { ServiceIntegrations } from './ServiceIntegrations';
import { isRumAgentName } from '../../../../common/agent_name';
import { useUrlParams } from '../../../hooks/useUrlParams';
function mapStateToProps(state = {} as IReduxState) {
return {
urlParams: getUrlParams(state)
};
export function ServiceDetails() {
const { urlParams } = useUrlParams();
const { serviceName, start, end, kuery } = urlParams;
const { data: serviceDetailsData } = useFetcher(
() => loadServiceDetails({ serviceName, start, end, kuery }),
[serviceName, start, end, kuery]
);
if (!serviceDetailsData) {
return null;
}
const isRumAgent = isRumAgentName(serviceDetailsData.agentName || '');
return (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="l">
<h1>{urlParams.serviceName}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIntegrations
transactionTypes={serviceDetailsData.types}
urlParams={urlParams}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<FilterBar />
<ServiceDetailTabs
urlParams={urlParams}
transactionTypes={serviceDetailsData.types}
isRumAgent={isRumAgent}
/>
</React.Fragment>
);
}
const ServiceDetails = connect(mapStateToProps)(ServiceDetailsView);
export { ServiceDetails };

View file

@ -1,65 +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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Location } from 'history';
import React from 'react';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceDetails } from '../../../services/rest/apm/services';
import { IUrlParams } from '../../../store/urlParams';
// @ts-ignore
import { FilterBar } from '../../shared/FilterBar';
import { ServiceDetailTabs } from './ServiceDetailTabs';
import { ServiceIntegrations } from './ServiceIntegrations';
import { isRumAgentName } from '../../../../common/agent_name';
interface Props {
urlParams: IUrlParams;
location: Location;
}
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]
);
if (!serviceDetailsData) {
return null;
}
const isRumAgent = isRumAgentName(serviceDetailsData.agentName || '');
return (
<React.Fragment>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size="l">
<h1>{urlParams.serviceName}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceIntegrations
transactionTypes={serviceDetailsData.types}
urlParams={urlParams}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<FilterBar />
<ServiceDetailTabs
location={location}
urlParams={urlParams}
transactionTypes={serviceDetailsData.types}
isRumAgent={isRumAgent}
/>
</React.Fragment>
);
}

View file

@ -5,23 +5,14 @@
*/
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 { toastNotifications } from 'ui/notify';
import * as apmRestServices from '../../../../services/rest/apm/services';
// @ts-ignore
import configureStore from '../../../../store/config/configureStore';
import { ServiceOverview } from '../view';
import { ServiceOverview } from '..';
function renderServiceOverview() {
const store = configureStore();
return render(
<Provider store={store}>
<ServiceOverview urlParams={{}} />
</Provider>
);
return render(<ServiceOverview />);
}
describe('Service Overview -> View', () => {

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { IReduxState } from '../../../store/rootReducer';
import { getUrlParams } from '../../../store/urlParams';
import { ServiceOverview as View } from './view';
function mapStateToProps(state = {} as IReduxState) {
return {
urlParams: getUrlParams(state)
};
}
export const ServiceOverview = connect(mapStateToProps)(View);

View file

@ -13,13 +13,9 @@ import { toastNotifications } from 'ui/notify';
import url from 'url';
import { useFetcher } from '../../../hooks/useFetcher';
import { loadServiceList } from '../../../services/rest/apm/services';
import { IUrlParams } from '../../../store/urlParams';
import { NoServicesMessage } from './NoServicesMessage';
import { ServiceList } from './ServiceList';
interface Props {
urlParams: IUrlParams;
}
import { useUrlParams } from '../../../hooks/useUrlParams';
const initalData = {
items: [],
@ -29,7 +25,8 @@ const initalData = {
let hasDisplayedToast = false;
export function ServiceOverview({ urlParams }: Props) {
export function ServiceOverview() {
const { urlParams } = useUrlParams();
const { start, end, kuery } = urlParams;
const { data = initalData } = useFetcher(
() => loadServiceList({ start, end, kuery }),

View file

@ -4,15 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { IReduxState } from '../../../store/rootReducer';
import { getUrlParams } from '../../../store/urlParams';
import { TraceOverview as View } from './view';
import { EuiPanel } from '@elastic/eui';
import React from 'react';
import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher';
import { loadTraceList } from '../../../services/rest/apm/traces';
import { TraceList } from './TraceList';
import { useUrlParams } from '../../../hooks/useUrlParams';
function mapStateToProps(state = {} as IReduxState) {
return {
urlParams: getUrlParams(state)
};
export function TraceOverview() {
const { urlParams } = useUrlParams();
const { start, end, kuery } = urlParams;
const { status, data = [] } = useFetcher(
() => loadTraceList({ start, end, kuery }),
[start, end, kuery]
);
return (
<EuiPanel>
<TraceList items={data} isLoading={status === FETCH_STATUS.LOADING} />
</EuiPanel>
);
}
export const TraceOverview = connect(mapStateToProps)(View);

View file

@ -1,30 +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 { EuiPanel } from '@elastic/eui';
import React from 'react';
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: IUrlParams;
}
export function TraceOverview(props: Props) {
const { start, end, kuery } = props.urlParams;
const { status, data = [] } = useFetcher(
() => loadTraceList({ start, end, kuery }),
[start, end, kuery]
);
return (
<EuiPanel>
<TraceList items={data} isLoading={status === FETCH_STATUS.LOADING} />
</EuiPanel>
);
}

View file

@ -11,12 +11,13 @@ import { Location } from 'history';
import React, { Component } from 'react';
import { ITransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution';
import { IBucket } from '../../../../../server/lib/transactions/distribution/get_buckets/transform';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { getTimeFormatter, timeUnit } from '../../../../utils/formatters';
// @ts-ignore
import Histogram from '../../../shared/charts/Histogram';
import { EmptyMessage } from '../../../shared/EmptyMessage';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { history } from '../../../../utils/history';
interface IChartPoint {
sample?: IBucket['sample'];

View file

@ -9,8 +9,9 @@ import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import { fromQuery, history, toQuery } from '../../../shared/Links/url_helpers';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { fromQuery, toQuery } from '../../../shared/Links/url_helpers';
import { history } from '../../../../utils/history';
import { TransactionMetadata } from '../../../shared/MetadataTable/TransactionMetadata';
import { WaterfallContainer } from './WaterfallContainer';
import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers';

View file

@ -9,15 +9,15 @@ import React, { Component } from 'react';
// @ts-ignore
import { StickyContainer } from 'react-sticky';
import styled from 'styled-components';
import { IUrlParams } from '../../../../../../store/urlParams';
import { IUrlParams } from '../../../../../../context/UrlParamsContext/types';
// @ts-ignore
import Timeline from '../../../../../shared/charts/Timeline';
import {
APMQueryParams,
fromQuery,
history,
toQuery
} from '../../../../../shared/Links/url_helpers';
import { history } from '../../../../../../utils/history';
import { AgentMark } from '../get_agent_marks';
import { SpanFlyout } from './SpanFlyout';
import { TransactionFlyout } from './TransactionFlyout';

View file

@ -7,7 +7,7 @@
import { Location } from 'history';
import React from 'react';
import { Transaction } from '../../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../../store/urlParams';
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
import { getAgentMarks } from './get_agent_marks';
import { ServiceLegends } from './ServiceLegends';
import { Waterfall } from './Waterfall';

View file

@ -17,7 +17,7 @@ import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import { Transaction as ITransaction } from '../../../../../typings/es_schemas/ui/Transaction';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { TransactionLink } from '../../../shared/Links/TransactionLink';
import { TransactionActionMenu } from '../../../shared/TransactionActionMenu/TransactionActionMenu';
import { StickyTransactionProperties } from './StickyTransactionProperties';

View file

@ -1,21 +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 { connect } from 'react-redux';
import { IReduxState } from '../../../store/rootReducer';
import { getUrlParams } from '../../../store/urlParams';
import { TransactionDetailsView } from './view';
function mapStateToProps(state = {} as IReduxState) {
return {
location: state.location,
urlParams: getUrlParams(state)
};
}
export const TransactionDetails = connect(mapStateToProps)(
TransactionDetailsView
);

View file

@ -6,25 +6,22 @@
import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import _ from 'lodash';
import React from 'react';
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';
import { FilterBar } from '../../shared/FilterBar';
import { TransactionDistribution } from './Distribution';
import { Transaction } from './Transaction';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
interface Props {
urlParams: IUrlParams;
location: Location;
}
export function TransactionDetailsView({ urlParams, location }: Props) {
export function TransactionDetails() {
const location = useLocation();
const { urlParams } = useUrlParams();
const { data: distributionData } = useTransactionDistribution(urlParams);
const { data: transactionDetailsChartsData } = useTransactionDetailsCharts(
urlParams

View file

@ -6,13 +6,10 @@
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';
import { TransactionOverview } from '..';
// @ts-ignore
import configureStore from '../../../../store/config/configureStore';
import { IUrlParams } from '../../../../store/urlParams';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
// Suppress warnings about "act" until async/await syntax is supported: https://github.com/facebook/react/issues/14769
/* eslint-disable no-console */
@ -28,16 +25,13 @@ 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>
<Router history={history}>
<TransactionOverview {...props} />
</Router>
);
return { container, history };

View file

@ -18,7 +18,7 @@ import React from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useTransactionList } from '../../../hooks/useTransactionList';
import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { TransactionCharts } from '../../shared/charts/TransactionCharts';
import { legacyEncodeURIComponent } from '../../shared/Links/url_helpers';
import { TransactionList } from './List';

View file

@ -1,37 +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.
*/
// Initially inspired from react-router's ConnectedRouter
// https://github.com/ReactTraining/react-router/blob/e6f9017c947b3ae49affa24cc320d0a86f765b55/packages/react-router-redux/modules/ConnectedRouter.js
// Instead of adding a listener to `history` we passively receive props from react-router
// This ensures that we don't have two history listeners (one here, and one for react-router) which can cause "race-condition" type issues
// since history.listen is sync and can result in cascading updates
import { Component } from 'react';
import PropTypes from 'prop-types';
class ConnectRouterToRedux extends Component {
static propTypes = {
location: PropTypes.object.isRequired
};
componentDidMount() {
this.props.updateLocation(this.props.location);
}
componentDidUpdate() {
// this component is wrapped in a react-router Route to get access
// to the location prop, so no need to check for prop change here
this.props.updateLocation(this.props.location);
}
render() {
return null;
}
}
export default ConnectRouterToRedux;

View file

@ -4,87 +4,60 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSuperDatePicker, EuiSuperDatePickerProps } from '@elastic/eui';
import { EuiSuperDatePicker } from '@elastic/eui';
import React from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { IReduxState } from '../../../store/rootReducer';
import {
getUrlParams,
IUrlParams,
refreshTimeRange
} from '../../../store/urlParams';
import { fromQuery, toQuery } from '../Links/url_helpers';
import { history } from '../../../utils/history';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
interface DatePickerProps extends RouteComponentProps {
dispatchRefreshTimeRange: typeof refreshTimeRange;
urlParams: IUrlParams;
}
export function DatePicker() {
const location = useLocation();
const { urlParams, refreshTimeRange } = useUrlParams();
export class DatePickerComponent extends React.Component<DatePickerProps> {
public updateUrl(nextQuery: {
function updateUrl(nextQuery: {
rangeFrom?: string;
rangeTo?: string;
refreshPaused?: boolean;
refreshInterval?: number;
}) {
const { history, location } = this.props;
history.push({
...location,
search: fromQuery({ ...toQuery(location.search), ...nextQuery })
search: fromQuery({
...toQuery(location.search),
...nextQuery
})
});
}
public onRefreshChange: EuiSuperDatePickerProps['onRefreshChange'] = ({
function onRefreshChange({
isPaused,
refreshInterval
}) => {
this.updateUrl({ refreshPaused: isPaused, refreshInterval });
};
public onTimeChange: EuiSuperDatePickerProps['onTimeChange'] = ({
start,
end
}) => {
this.updateUrl({ rangeFrom: start, rangeTo: end });
};
public onRefresh: EuiSuperDatePickerProps['onRefresh'] = ({ start, end }) => {
this.props.dispatchRefreshTimeRange({ rangeFrom: start, rangeTo: end });
};
public render() {
const {
rangeFrom,
rangeTo,
refreshPaused,
refreshInterval
} = this.props.urlParams;
return (
<EuiSuperDatePicker
start={rangeFrom}
end={rangeTo}
isPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeChange={this.onTimeChange}
onRefresh={this.onRefresh}
onRefreshChange={this.onRefreshChange}
showUpdateButton={true}
/>
);
}: {
isPaused: boolean;
refreshInterval: number;
}) {
updateUrl({ refreshPaused: isPaused, refreshInterval });
}
function onTimeChange({ start, end }: { start: string; end: string }) {
updateUrl({ rangeFrom: start, rangeTo: end });
}
const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams;
return (
<EuiSuperDatePicker
start={rangeFrom}
end={rangeTo}
isPaused={refreshPaused}
refreshInterval={refreshInterval}
onTimeChange={onTimeChange}
onRefresh={({ start, end }) => {
refreshTimeRange({ rangeFrom: start, rangeTo: end });
}}
onRefreshChange={onRefreshChange}
showUpdateButton={true}
/>
);
}
const mapStateToProps = (state: IReduxState) => ({
urlParams: getUrlParams(state)
});
const mapDispatchToProps = { dispatchRefreshTimeRange: refreshTimeRange };
const DatePicker = withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(DatePickerComponent)
);
export { DatePicker };

View file

@ -4,158 +4,87 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { Store } from 'redux';
// @ts-ignore
import configureStore from '../../../../store/config/configureStore';
import { mockNow, tick } from '../../../../utils/testHelpers';
import { DatePicker, DatePickerComponent } from '../DatePicker';
import { LocationProvider } from '../../../../context/LocationContext';
import { UrlParamsContext } from '../../../../context/UrlParamsContext';
import { tick } from '../../../../utils/testHelpers';
import { DatePicker } from '../DatePicker';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { history } from '../../../../utils/history';
import { mount } from 'enzyme';
import { EuiSuperDatePicker } from '@elastic/eui';
function mountPicker(initialState = {}) {
const store = configureStore(initialState);
const wrapper = mount(
<Provider store={store}>
<MemoryRouter>
const mockHistoryPush = jest.spyOn(history, 'push');
const mockRefreshTimeRange = jest.fn();
const MockUrlParamsProvider: React.FC<{
params?: IUrlParams;
}> = ({ params = {}, children }) => (
<UrlParamsContext.Provider
value={{ urlParams: params, refreshTimeRange: mockRefreshTimeRange }}
children={children}
/>
);
function mountDatePicker(params?: IUrlParams) {
return mount(
<LocationProvider history={history}>
<MockUrlParamsProvider params={params}>
<DatePicker />
</MemoryRouter>
</Provider>
</MockUrlParamsProvider>
</LocationProvider>
);
return { wrapper, store };
}
describe('DatePicker', () => {
describe('url updates', () => {
function setupTest() {
const routerProps = {
location: { search: '' },
history: { push: jest.fn() }
} as any;
const wrapper = shallow<DatePickerComponent>(
<DatePickerComponent
{...routerProps}
dispatchUpdateTimePicker={jest.fn()}
urlParams={{}}
/>
);
return { history: routerProps.history, wrapper };
}
it('should push an entry to the stack for each change', () => {
const { history, wrapper } = setupTest();
wrapper.instance().updateUrl({ rangeFrom: 'now-20m', rangeTo: 'now' });
expect(history.push).toHaveBeenCalledWith({
search: 'rangeFrom=now-20m&rangeTo=now'
});
});
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => null);
});
describe('refresh cycle', () => {
let nowSpy: jest.Mock;
beforeEach(() => {
nowSpy = mockNow('2010');
jest.useFakeTimers();
});
afterAll(() => {
jest.restoreAllMocks();
});
afterEach(() => {
nowSpy.mockRestore();
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('when refresh is not paused', () => {
let listener: jest.Mock;
let store: Store;
beforeEach(async () => {
const obj = mountPicker({
urlParams: {
rangeFrom: 'now-15m',
rangeTo: 'now',
refreshPaused: false,
refreshInterval: 200
}
});
store = obj.store;
listener = jest.fn();
store.subscribe(listener);
jest.advanceTimersByTime(200);
await tick();
jest.advanceTimersByTime(200);
await tick();
jest.advanceTimersByTime(200);
await tick();
it('should update the URL when the date range changes', () => {
const datePicker = mountDatePicker();
datePicker
.find(EuiSuperDatePicker)
.props()
.onTimeChange({
start: 'updated-start',
end: 'updated-end',
isInvalid: false,
isQuickSelection: true
});
expect(mockHistoryPush).toHaveBeenCalledWith(
expect.objectContaining({
search: 'rangeFrom=updated-start&rangeTo=updated-end'
})
);
});
it('should dispatch every refresh interval', async () => {
expect(listener).toHaveBeenCalledTimes(3);
});
it('should update the store with the new date range', () => {
expect(store.getState().urlParams).toEqual({
end: '2010-01-01T00:00:00.000Z',
rangeFrom: 'now-15m',
rangeTo: 'now',
refreshInterval: 200,
refreshPaused: false,
start: '2009-12-31T23:45:00.000Z'
});
});
it('should auto-refresh when refreshPaused is false', async () => {
jest.useFakeTimers();
const wrapper = mountDatePicker({
refreshPaused: false,
refreshInterval: 1000
});
expect(mockRefreshTimeRange).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
await tick();
expect(mockRefreshTimeRange).toHaveBeenCalled();
wrapper.unmount();
});
it('should not refresh when paused', () => {
const { store } = mountPicker({
urlParams: {
rangeFrom: 'now-15m',
rangeTo: 'now',
refreshPaused: true,
refreshInterval: 200
}
});
const listener = jest.fn();
store.subscribe(listener);
jest.advanceTimersByTime(1100);
expect(listener).not.toHaveBeenCalled();
});
it('should be paused by default', () => {
const { store } = mountPicker({
urlParams: {
rangeFrom: 'now-15m',
rangeTo: 'now',
refreshInterval: 200
}
});
const listener = jest.fn();
store.subscribe(listener);
jest.advanceTimersByTime(1100);
expect(listener).not.toHaveBeenCalled();
});
it('should not attempt refreshes after unmounting', () => {
const { store, wrapper } = mountPicker({
urlParams: {
rangeFrom: 'now-15m',
rangeTo: 'now',
refreshPaused: false,
refreshInterval: 200
}
});
const listener = jest.fn();
store.subscribe(listener);
wrapper.unmount();
jest.advanceTimersByTime(1100);
expect(listener).not.toHaveBeenCalled();
});
it('should NOT auto-refresh when refreshPaused is true', async () => {
jest.useFakeTimers();
mountDatePicker({ refreshPaused: true, refreshInterval: 1000 });
expect(mockRefreshTimeRange).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
await tick();
expect(mockRefreshTimeRange).not.toHaveBeenCalled();
});
});

View file

@ -5,15 +5,11 @@
*/
import { EuiTab } from '@elastic/eui';
import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme';
import { shallow, ShallowWrapper } from 'enzyme';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import {
HistoryTabs,
HistoryTabsProps,
HistoryTabsWithoutRouter,
IHistoryTab
} from '..';
import { HistoryTabs, HistoryTabsProps, IHistoryTab } from '..';
import * as hooks from '../../../../hooks/useLocation';
import { history } from '../../../../utils/history';
type PropsOf<Component> = Component extends React.SFC<infer Props>
? Props
@ -22,7 +18,6 @@ type EuiTabProps = PropsOf<typeof EuiTab>;
describe('HistoryTabs', () => {
let mockLocation: any;
let mockHistory: any;
let testTabs: IHistoryTab[];
let testProps: HistoryTabsProps;
@ -30,9 +25,8 @@ describe('HistoryTabs', () => {
mockLocation = {
pathname: ''
};
mockHistory = {
push: jest.fn()
};
jest.spyOn(hooks, 'useLocation').mockImplementation(() => mockLocation);
const Content = (props: { name: string }) => <div>{props.name}</div>;
@ -55,15 +49,17 @@ describe('HistoryTabs', () => {
];
testProps = {
location: mockLocation,
history: mockHistory,
tabs: testTabs
} as HistoryTabsProps;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render correctly', () => {
mockLocation.pathname = '/two';
const wrapper = shallow(<HistoryTabsWithoutRouter {...testProps} />);
const wrapper = shallow(<HistoryTabs {...testProps} />);
expect(wrapper).toMatchSnapshot();
const tabs: ShallowWrapper<EuiTabProps> = wrapper.find(EuiTab);
@ -72,25 +68,15 @@ describe('HistoryTabs', () => {
expect(tabs.at(2).props().isSelected).toEqual(false);
});
it('should change the selected item on tab click', () => {
const wrapper = mount(
<MemoryRouter initialEntries={['/two']}>
<HistoryTabs tabs={testTabs} />
</MemoryRouter>
);
expect(wrapper.find('Content')).toMatchSnapshot();
it('should push a new state onto history on tab click', () => {
const pushSpy = jest.spyOn(history, 'push');
const wrapper = shallow(<HistoryTabs tabs={testTabs} />);
wrapper
.find(EuiTab)
.at(2)
.simulate('click');
const tabs: ReactWrapper<EuiTabProps> = wrapper.find(EuiTab);
expect(tabs.at(0).props().isSelected).toEqual(false);
expect(tabs.at(1).props().isSelected).toEqual(false);
expect(tabs.at(2).props().isSelected).toEqual(true);
expect(wrapper.find('Content')).toMatchSnapshot();
expect(pushSpy).toHaveBeenCalledWith({ pathname: '/three' });
});
});

View file

@ -1,25 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HistoryTabs should change the selected item on tab click 1`] = `
<Content
name="two"
>
<div>
two
</div>
</Content>
`;
exports[`HistoryTabs should change the selected item on tab click 2`] = `
<Content
name="three"
>
<div>
three
</div>
</Content>
`;
exports[`HistoryTabs should render correctly 1`] = `
<Fragment>
<EuiTabs

View file

@ -6,12 +6,9 @@
import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui';
import React from 'react';
import {
matchPath,
Route,
RouteComponentProps,
withRouter
} from 'react-router-dom';
import { matchPath, Route, RouteComponentProps } from 'react-router-dom';
import { useLocation } from '../../../hooks/useLocation';
import { history } from '../../../utils/history';
export interface IHistoryTab {
path: string;
@ -20,7 +17,7 @@ export interface IHistoryTab {
render?: (props: RouteComponentProps) => React.ReactNode;
}
export interface HistoryTabsProps extends RouteComponentProps {
export interface HistoryTabsProps {
tabs: IHistoryTab[];
}
@ -31,11 +28,8 @@ function isTabSelected(tab: IHistoryTab, currentPath: string) {
return currentPath === tab.path;
}
const HistoryTabsWithoutRouter = ({
tabs,
history,
location
}: HistoryTabsProps) => {
export function HistoryTabs({ tabs }: HistoryTabsProps) {
const location = useLocation();
return (
<React.Fragment>
<EuiTabs>
@ -61,8 +55,4 @@ const HistoryTabsWithoutRouter = ({
)}
</React.Fragment>
);
};
const HistoryTabs = withRouter(HistoryTabsWithoutRouter);
export { HistoryTabsWithoutRouter, HistoryTabs };
}

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { getUrlParams } from '../../../store/urlParams';
import view from './view';
function mapStateToProps(state = {}) {
return {
location: state.location,
urlParams: getUrlParams(state)
};
}
export const KueryBar = connect(mapStateToProps)(view);

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import { uniqueId, startsWith } from 'lodash';
import { EuiCallOut } from '@elastic/eui';
import chrome from 'ui/chrome';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import { StaticIndexPattern } from 'ui/index_patterns';
import {
fromQuery,
toQuery,
legacyEncodeURIComponent
} from '../Links/url_helpers';
import { KibanaLink } from '../Links/KibanaLink';
// @ts-ignore
import { Typeahead } from './Typeahead';
import {
convertKueryToEsQuery,
getSuggestions,
getAPMIndexPatternForKuery
} from '../../../services/kuery';
// @ts-ignore
import { getBoolFilter } from './get_bool_filter';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { history } from '../../../utils/history';
const Container = styled.div`
margin-bottom: 10px;
`;
interface State {
indexPattern: StaticIndexPattern | null;
suggestions: AutocompleteSuggestion[];
isLoadingIndexPattern: boolean;
isLoadingSuggestions: boolean;
}
export function KueryBar() {
const [state, setState] = useState<State>({
indexPattern: null,
suggestions: [],
isLoadingIndexPattern: true,
isLoadingSuggestions: false
});
const { urlParams } = useUrlParams();
const location = useLocation();
const apmIndexPatternTitle = chrome.getInjected('apmIndexPatternTitle');
const indexPatternMissing =
!state.isLoadingIndexPattern && !state.indexPattern;
let currentRequestCheck;
useEffect(() => {
let didCancel = false;
async function loadIndexPattern() {
setState({ ...state, isLoadingIndexPattern: true });
const indexPattern = await getAPMIndexPatternForKuery();
if (didCancel) {
return;
}
if (!indexPattern) {
setState({ ...state, isLoadingIndexPattern: false });
} else {
setState({ ...state, indexPattern, isLoadingIndexPattern: false });
}
}
loadIndexPattern();
return () => {
didCancel = true;
};
}, []);
async function onChange(inputValue: string, selectionStart: number) {
const { indexPattern } = state;
if (indexPattern === null) {
return;
}
setState({ ...state, suggestions: [], isLoadingSuggestions: true });
const currentRequest = uniqueId();
currentRequestCheck = currentRequest;
const boolFilter = getBoolFilter(urlParams);
try {
const suggestions = (await getSuggestions(
inputValue,
selectionStart,
indexPattern,
boolFilter
))
.filter(suggestion => !startsWith(suggestion.text, 'span.'))
.slice(0, 15);
if (currentRequest !== currentRequestCheck) {
return;
}
setState({ ...state, suggestions, isLoadingSuggestions: false });
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error while fetching suggestions', e);
}
}
function onSubmit(inputValue: string) {
const { indexPattern } = state;
if (indexPattern === null) {
return;
}
try {
const res = convertKueryToEsQuery(inputValue, indexPattern);
if (!res) {
return;
}
history.replace({
...location,
search: fromQuery({
...toQuery(location.search),
kuery: legacyEncodeURIComponent(inputValue.trim())
})
});
} catch (e) {
console.log('Invalid kuery syntax'); // eslint-disable-line no-console
}
}
return (
<Container>
<Typeahead
disabled={indexPatternMissing}
isLoading={state.isLoadingSuggestions}
initialValue={urlParams.kuery}
onChange={onChange}
onSubmit={onSubmit}
suggestions={state.suggestions}
/>
{indexPatternMissing && (
<EuiCallOut
style={{ display: 'inline-block', marginTop: '10px' }}
title={
<div>
<FormattedMessage
id="xpack.apm.kueryBar.indexPatternMissingWarningMessage"
defaultMessage="There's no APM index pattern with the title {apmIndexPatternTitle} available. To use the Query bar, please choose to import the APM index pattern via the {setupInstructionsLink}."
values={{
apmIndexPatternTitle: `"${apmIndexPatternTitle}"`,
setupInstructionsLink: (
<KibanaLink path={`/home/tutorial/apm`}>
{i18n.translate(
'xpack.apm.kueryBar.setupInstructionsLinkLabel',
{ defaultMessage: 'Setup Instructions' }
)}
</KibanaLink>
)
}}
/>
</div>
}
color="warning"
iconType="alert"
size="s"
/>
)}
</Container>
);
}

View file

@ -1,158 +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, { Component } from 'react';
import PropTypes from 'prop-types';
import { uniqueId, startsWith } from 'lodash';
import { EuiCallOut } from '@elastic/eui';
import {
history,
fromQuery,
toQuery,
legacyEncodeURIComponent
} from '../Links/url_helpers';
import { KibanaLink } from '../Links/KibanaLink';
import { Typeahead } from './Typeahead';
import chrome from 'ui/chrome';
import {
convertKueryToEsQuery,
getSuggestions,
getAPMIndexPatternForKuery
} from '../../../services/kuery';
import styled from 'styled-components';
import { getBoolFilter } from './get_bool_filter';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
const Container = styled.div`
margin-bottom: 10px;
`;
class KueryBarView extends Component {
state = {
indexPattern: null,
suggestions: [],
isLoadingIndexPattern: true,
isLoadingSuggestions: false
};
willUnmount = false;
componentWillUnmount() {
this.willUnmount = true;
}
async componentDidMount() {
const indexPattern = await getAPMIndexPatternForKuery();
if (!this.willUnmount) {
this.setState({ indexPattern, isLoadingIndexPattern: false });
}
}
onChange = async (inputValue, selectionStart) => {
const { indexPattern } = this.state;
const { urlParams } = this.props;
this.setState({ suggestions: [], isLoadingSuggestions: true });
const currentRequest = uniqueId();
this.currentRequest = currentRequest;
const boolFilter = getBoolFilter(urlParams);
try {
const suggestions = (await getSuggestions(
inputValue,
selectionStart,
indexPattern,
boolFilter
))
.filter(suggestion => !startsWith(suggestion.text, 'span.'))
.slice(0, 15);
if (currentRequest !== this.currentRequest) {
return;
}
this.setState({ suggestions, isLoadingSuggestions: false });
} catch (e) {
console.error('Error while fetching suggestions', e);
}
};
onSubmit = inputValue => {
const { indexPattern } = this.state;
const { location } = this.props;
try {
const res = convertKueryToEsQuery(inputValue, indexPattern);
if (!res) {
return;
}
history.replace({
...location,
search: fromQuery({
...toQuery(this.props.location.search),
kuery: legacyEncodeURIComponent(inputValue.trim())
})
});
} catch (e) {
console.log('Invalid kuery syntax'); // eslint-disable-line no-console
}
};
render() {
const apmIndexPatternTitle = chrome.getInjected('apmIndexPatternTitle');
const indexPatternMissing =
!this.state.isLoadingIndexPattern && !this.state.indexPattern;
return (
<Container>
<Typeahead
disabled={indexPatternMissing}
isLoading={this.state.isLoadingSuggestions}
initialValue={this.props.urlParams.kuery}
onChange={this.onChange}
onSubmit={this.onSubmit}
suggestions={this.state.suggestions}
/>
{indexPatternMissing && (
<EuiCallOut
style={{ display: 'inline-block', marginTop: '10px' }}
title={
<div>
<FormattedMessage
id="xpack.apm.kueryBar.indexPatternMissingWarningMessage"
defaultMessage="There's no APM index pattern with the title {apmIndexPatternTitle} available. To use the Query bar, please choose to import the APM index pattern via the {setupInstructionsLink}."
values={{
apmIndexPatternTitle: `"${apmIndexPatternTitle}"`,
setupInstructionsLink: (
<KibanaLink path={`/home/tutorial/apm`}>
{i18n.translate(
'xpack.apm.kueryBar.setupInstructionsLinkLabel',
{ defaultMessage: 'Setup Instructions' }
)}
</KibanaLink>
)
}}
/>
</div>
}
color="warning"
iconType="alert"
size="s"
/>
)}
</Container>
);
}
}
KueryBarView.propTypes = {
location: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
export default KueryBarView;

View file

@ -10,7 +10,7 @@ import url from 'url';
import { pick } from 'lodash';
import { useLocation } from '../../../hooks/useLocation';
import { APMQueryParams, toQuery, fromQuery } from './url_helpers';
import { TIMEPICKER_DEFAULTS } from '../../../store/urlParams';
import { TIMEPICKER_DEFAULTS } from '../../../context/UrlParamsContext/constants';
interface Props extends EuiLinkAnchorProps {
path?: string;

View file

@ -5,7 +5,7 @@
*/
import { Location } from 'history';
import { TIMEPICKER_DEFAULTS } from '../../../store/urlParams';
import { TIMEPICKER_DEFAULTS } from '../../../context/UrlParamsContext/constants';
import { toQuery } from './url_helpers';
export interface TimepickerRisonData {

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import createHistory from 'history/createHashHistory';
import qs from 'querystring';
import { StringMap } from '../../../../typings/common';
@ -52,9 +51,3 @@ export function legacyEncodeURIComponent(rawUrl?: string) {
export function legacyDecodeURIComponent(encodedUrl?: string) {
return encodedUrl && decodeURIComponent(encodedUrl.replace(/~/g, '%'));
}
// Make history singleton available across APM project.
// This is not great. Other options are to use context or withRouter helper
// React Context API is unstable and will change soon-ish (probably 16.3)
// withRouter helper from react-router overrides several props (eg. `location`) which makes it less desireable
export const history = createHistory();

View file

@ -16,7 +16,7 @@ import InteractivePlot from '../InteractivePlot';
import {
getResponseTimeSeries,
getEmptySerie
} from '../../../../../store/selectors/chartSelectors';
} from '../../../../../selectors/chartSelectors';
function getXValueByIndex(index) {
return responseWithData.responseTimes.avg[index].x;

View file

@ -19,8 +19,8 @@ import React, { Component } from 'react';
import { isEmpty } from 'lodash';
import styled from 'styled-components';
import { Coordinate } from '../../../../../typings/timeseries';
import { ITransactionChartData } from '../../../../store/selectors/chartSelectors';
import { IUrlParams } from '../../../../store/urlParams';
import { ITransactionChartData } from '../../../../selectors/chartSelectors';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
import { asInteger, asMillis, tpmUnit } from '../../../../utils/formatters';
import { LicenseContext } from '../../../app/Main/LicenseCheck';
import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink';

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { UrlParamsContext, UrlParamsProvider } from '..';
import { mount } from 'enzyme';
import * as hooks from '../../../hooks/useLocation';
import { Location } from 'history';
import { IUrlParams } from '../types';
function mountParams() {
return mount(
<UrlParamsProvider>
<UrlParamsContext.Consumer>
{({ urlParams }: { urlParams: IUrlParams }) => (
<span id="data">{JSON.stringify(urlParams, null, 2)}</span>
)}
</UrlParamsContext.Consumer>
</UrlParamsProvider>
);
}
function getDataFromOutput(wrapper: ReturnType<typeof mount>) {
return JSON.parse(wrapper.find('#data').text());
}
describe('UrlParamsContext', () => {
let mockLocation: Location;
beforeEach(() => {
mockLocation = { pathname: '/test/pathname' } as Location;
jest.spyOn(hooks, 'useLocation').mockImplementation(() => mockLocation);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should have default params', () => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2000-06-15T12:00:00Z').getTime());
const wrapper = mountParams();
const params = getDataFromOutput(wrapper);
expect(params).toEqual({
start: '2000-06-14T12:00:00.000Z',
end: '2000-06-15T12:00:00.000Z',
page: 0,
rangeFrom: 'now-24h',
rangeTo: 'now',
refreshInterval: 0,
refreshPaused: true
});
});
it('should read values in from location', () => {
mockLocation.search =
'?rangeFrom=2010-03-15T12:00:00Z&rangeTo=2010-04-10T12:00:00Z&transactionId=123abc';
const wrapper = mountParams();
const params = getDataFromOutput(wrapper);
expect(params.start).toEqual('2010-03-15T12:00:00.000Z');
expect(params.end).toEqual('2010-04-10T12:00:00.000Z');
});
it('should update param values if location has changed', () => {
const wrapper = mountParams();
mockLocation = {
pathname: '/test/updated',
search:
'?rangeFrom=2009-03-15T12:00:00Z&rangeTo=2009-04-10T12:00:00Z&transactionId=UPDATED'
} as Location;
// force an update
wrapper.setProps({ abc: 123 });
const params = getDataFromOutput(wrapper);
expect(params.start).toEqual('2009-03-15T12:00:00.000Z');
expect(params.end).toEqual('2009-04-10T12:00:00.000Z');
});
it('should refresh the time range with new values', () => {
const wrapper = mount(
<UrlParamsProvider>
<UrlParamsContext.Consumer>
{({ urlParams, refreshTimeRange }) => {
return (
<React.Fragment>
<span id="data">{JSON.stringify(urlParams, null, 2)}</span>
<button
onClick={() =>
refreshTimeRange({
rangeFrom: '2005-09-20T12:00:00Z',
rangeTo: '2005-10-21T12:00:00Z'
})
}
/>
</React.Fragment>
);
}}
</UrlParamsContext.Consumer>
</UrlParamsProvider>
);
wrapper.find('button').simulate('click');
const data = getDataFromOutput(wrapper);
expect(data.start).toEqual('2005-09-20T12:00:00.000Z');
expect(data.end).toEqual('2005-10-21T12:00:00.000Z');
});
});

View file

@ -4,14 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import view from './view';
import { updateLocation } from '../../../store/location';
const mapDispatchToProps = {
updateLocation
export const TIME_RANGE_REFRESH = 'TIME_RANGE_REFRESH';
export const LOCATION_UPDATE = 'LOCATION_UPDATE';
export const TIMEPICKER_DEFAULTS = {
rangeFrom: 'now-24h',
rangeTo: 'now',
refreshPaused: 'true',
refreshInterval: '0'
};
export default connect(
null,
mapDispatchToProps
)(view);

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { compact, pick } from 'lodash';
import datemath from '@elastic/datemath';
import { IUrlParams } from './types';
export function getParsedDate(rawDate?: string, opts = {}) {
if (rawDate) {
const parsed = datemath.parse(rawDate, opts);
if (parsed) {
return parsed.toISOString();
}
}
}
export function getStart(prevState: IUrlParams, rangeFrom?: string) {
if (prevState.rangeFrom !== rangeFrom) {
return getParsedDate(rangeFrom);
}
return prevState.start;
}
export function getEnd(prevState: IUrlParams, rangeTo?: string) {
if (prevState.rangeTo !== rangeTo) {
return getParsedDate(rangeTo, { roundUp: true });
}
return prevState.end;
}
export function toNumber(value?: string) {
if (value !== undefined) {
return parseInt(value, 10);
}
}
export function toString(value?: string) {
if (value === '' || value === 'null' || value === 'undefined') {
return;
}
return value;
}
export function toBoolean(value?: string) {
return value === 'true';
}
export function getPathAsArray(pathname: string = '') {
return compact(pathname.split('/'));
}
export function removeUndefinedProps<T>(obj: T): Partial<T> {
return pick(obj, value => value !== undefined);
}
export function getPathParams(pathname: string = '') {
const paths = getPathAsArray(pathname);
const pageName = paths[1];
switch (pageName) {
case 'transactions':
return {
processorEvent: 'transaction',
serviceName: paths[0],
transactionType: paths[2],
transactionName: paths[3]
};
case 'errors':
return {
processorEvent: 'error',
serviceName: paths[0],
errorGroupId: paths[2]
};
case 'metrics':
return {
processorEvent: 'metric',
serviceName: paths[0]
};
default:
return {};
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useReducer, useEffect } from 'react';
import { Location } from 'history';
import { useLocation } from '../../hooks/useLocation';
import { IUrlParams } from './types';
import { LOCATION_UPDATE, TIME_RANGE_REFRESH } from './constants';
import { getParsedDate } from './helpers';
import { resolveUrlParams } from './resolveUrlParams';
interface TimeRange {
rangeFrom: string;
rangeTo: string;
}
interface LocationAction {
type: typeof LOCATION_UPDATE;
location: Location;
}
interface TimeRangeRefreshAction {
type: typeof TIME_RANGE_REFRESH;
time: TimeRange;
}
const defaultRefresh = (time: TimeRange) => {};
export function urlParamsReducer(
state: IUrlParams = {},
action: LocationAction | TimeRangeRefreshAction
): IUrlParams {
switch (action.type) {
case LOCATION_UPDATE: {
return resolveUrlParams(action.location);
}
case TIME_RANGE_REFRESH:
return {
...state,
start: getParsedDate(action.time.rangeFrom),
end: getParsedDate(action.time.rangeTo)
};
default:
return state;
}
}
const UrlParamsContext = createContext({
urlParams: {} as IUrlParams,
refreshTimeRange: defaultRefresh
});
const UrlParamsProvider: React.FC<{}> = ({ children }) => {
const location = useLocation();
const [urlParams, dispatch] = useReducer(
urlParamsReducer,
resolveUrlParams(location)
);
function refreshTimeRange(time: TimeRange) {
dispatch({ type: TIME_RANGE_REFRESH, time });
}
useEffect(
() => {
dispatch({ type: LOCATION_UPDATE, location });
},
[location]
);
return (
<UrlParamsContext.Provider
children={children}
value={{ urlParams, refreshTimeRange }}
/>
);
};
export { UrlParamsContext, UrlParamsProvider };

View file

@ -0,0 +1,77 @@
/*
* 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 { Location } from 'history';
import { IUrlParams } from './types';
import {
getPathParams,
removeUndefinedProps,
getStart,
getEnd,
toBoolean,
toNumber,
toString
} from './helpers';
import {
toQuery,
legacyDecodeURIComponent
} from '../../components/shared/Links/url_helpers';
import { TIMEPICKER_DEFAULTS } from './constants';
export function resolveUrlParams(location: Location, state: IUrlParams = {}) {
const {
processorEvent,
serviceName,
transactionName,
transactionType,
errorGroupId
} = getPathParams(location.pathname);
const {
traceId,
transactionId,
detailTab,
flyoutDetailTab,
waterfallItemId,
spanId,
page,
sortDirection,
sortField,
kuery,
refreshPaused = TIMEPICKER_DEFAULTS.refreshPaused,
refreshInterval = TIMEPICKER_DEFAULTS.refreshInterval,
rangeFrom = TIMEPICKER_DEFAULTS.rangeFrom,
rangeTo = TIMEPICKER_DEFAULTS.rangeTo
} = toQuery(location.search);
return removeUndefinedProps({
...state,
// date params
start: getStart(state, rangeFrom),
end: getEnd(state, rangeTo),
rangeFrom,
rangeTo,
refreshPaused: toBoolean(refreshPaused),
refreshInterval: toNumber(refreshInterval),
// query params
sortDirection,
sortField,
page: toNumber(page) || 0,
transactionId: toString(transactionId),
traceId: toString(traceId),
waterfallItemId: toString(waterfallItemId),
detailTab: toString(detailTab),
flyoutDetailTab: toString(flyoutDetailTab),
spanId: toNumber(spanId),
kuery: legacyDecodeURIComponent(kuery),
// path params
processorEvent,
serviceName,
transactionType: legacyDecodeURIComponent(transactionType),
transactionName: legacyDecodeURIComponent(transactionName),
errorGroupId
});
}

View file

@ -0,0 +1,26 @@
/*
* 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.
*/
export interface IUrlParams {
detailTab?: string;
end?: string;
errorGroupId?: string;
flyoutDetailTab?: string;
kuery?: string;
rangeFrom?: string;
rangeTo?: string;
refreshInterval?: number;
refreshPaused?: boolean;
serviceName?: string;
sortDirection?: string;
sortField?: string;
start?: string;
traceId?: string;
transactionId?: string;
transactionName?: string;
transactionType?: string;
waterfallItemId?: string;
}

View file

@ -8,11 +8,8 @@ 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 { getCPUSeries, getMemorySeries } from '../selectors/chartSelectors';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
const memory: MemoryChartAPIResponse = {

View file

@ -6,8 +6,8 @@
import { useMemo } from 'react';
import { loadTransactionDetailsCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../store/selectors/chartSelectors';
import { IUrlParams } from '../store/urlParams';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
export function useTransactionDetailsCharts(urlParams: IUrlParams) {

View file

@ -5,7 +5,7 @@
*/
import { loadTransactionDistribution } from '../services/rest/apm/transaction_groups';
import { IUrlParams } from '../store/urlParams';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
const INITIAL_DATA = {

View file

@ -7,7 +7,7 @@
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 { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
const getRelativeImpact = (

View file

@ -6,8 +6,8 @@
import { useMemo } from 'react';
import { loadTransactionOverviewCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../store/selectors/chartSelectors';
import { IUrlParams } from '../store/urlParams';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
export function useTransactionOverviewCharts(urlParams: IUrlParams) {

View file

@ -4,8 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import storeProd from './configureStore.prod';
import storeDev from './configureStore.dev';
import { useContext } from 'react';
import { UrlParamsContext } from '../context/UrlParamsContext';
const store = process.env.NODE_ENV === 'production' ? storeProd : storeDev;
export default store;
export function useUrlParams() {
return useContext(UrlParamsContext);
}

View file

@ -7,7 +7,7 @@
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 { IUrlParams } from '../context/UrlParamsContext/types';
import { useFetcher } from './useFetcher';
const INITIAL_DATA = { trace: [], errorsPerTransaction: {} };

View file

@ -16,8 +16,6 @@ import { I18nContext } from 'ui/i18n';
import { uiModules } from 'ui/modules';
import 'uiExports/autocompleteProviders';
import { GlobalHelpExtension } from './components/app/GlobalHelpExtension';
// @ts-ignore
import configureStore from './store/config/configureStore';
import { plugin } from './new-platform';
import { REACT_APP_ROOT_ID } from './new-platform/plugin';
import './style/global_overrides.css';

View file

@ -6,30 +6,27 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { CoreSetup } from 'src/core/public';
import { Main } from '../components/app/Main';
import { history } from '../components/shared/Links/url_helpers';
import { history } from '../utils/history';
import { LocationProvider } from '../context/LocationContext';
// @ts-ignore
import configureStore from '../store/config/configureStore';
import { UrlParamsProvider } from '../context/UrlParamsContext';
export const REACT_APP_ROOT_ID = 'react-apm-root';
export class Plugin {
public setup(core: CoreSetup) {
const { i18n } = core;
const store = configureStore();
ReactDOM.render(
<i18n.Context>
<Provider store={store}>
<Router history={history}>
<LocationProvider history={history}>
<Router history={history}>
<LocationProvider history={history}>
<UrlParamsProvider>
<Main />
</LocationProvider>
</Router>
</Provider>
</UrlParamsProvider>
</LocationProvider>
</Router>
</i18n.Context>,
document.getElementById(REACT_APP_ROOT_ID)
);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ApmTimeSeriesResponse } from '../../../../server/lib/transactions/charts/get_timeseries_data/transform';
import { ApmTimeSeriesResponse } from '../../../server/lib/transactions/charts/get_timeseries_data/transform';
import {
getAnomalyScoreSeries,
getResponseTimeSeries,

View file

@ -10,18 +10,13 @@ import d3 from 'd3';
import { difference, memoize, zipObject } from 'lodash';
import mean from 'lodash.mean';
import { rgba } from 'polished';
import { MetricsChartAPIResponse } from '../../../server/lib/metrics/get_all_metrics_chart_data';
import { TimeSeriesAPIResponse } from '../../../server/lib/transactions/charts';
import { ApmTimeSeriesResponse } from '../../../server/lib/transactions/charts/get_timeseries_data/transform';
import { StringMap } from '../../../typings/common';
import { Coordinate, RectCoordinate } from '../../../typings/timeseries';
import {
asDecimal,
asMillis,
asPercent,
tpmUnit
} from '../../utils/formatters';
import { IUrlParams } from '../urlParams';
import { MetricsChartAPIResponse } from '../../server/lib/metrics/get_all_metrics_chart_data';
import { TimeSeriesAPIResponse } from '../../server/lib/transactions/charts';
import { ApmTimeSeriesResponse } from '../../server/lib/transactions/charts/get_timeseries_data/transform';
import { StringMap } from '../../typings/common';
import { Coordinate, RectCoordinate } from '../../typings/timeseries';
import { asDecimal, asMillis, asPercent, tpmUnit } from '../utils/formatters';
import { IUrlParams } from '../context/UrlParamsContext/types';
export const getEmptySerie = memoize(
(

View file

@ -8,7 +8,7 @@ import { ErrorDistributionAPIResponse } from '../../../../server/lib/errors/dist
import { ErrorGroupAPIResponse } from '../../../../server/lib/errors/get_error_group';
import { ErrorGroupListAPIResponse } from '../../../../server/lib/errors/get_error_groups';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';

View file

@ -5,7 +5,7 @@
*/
import { MetricsChartAPIResponse } from '../../../../server/lib/metrics/get_all_metrics_chart_data';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';

View file

@ -7,7 +7,7 @@
import { ServiceAPIResponse } from '../../../../server/lib/services/get_service';
import { ServiceListAPIResponse } from '../../../../server/lib/services/get_services';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';

View file

@ -7,7 +7,7 @@
import { TraceListAPIResponse } from '../../../../server/lib/traces/get_top_traces';
import { TraceAPIResponse } from '../../../../server/lib/traces/get_trace';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';

View file

@ -8,7 +8,7 @@ import { TimeSeriesAPIResponse } from '../../../../server/lib/transactions/chart
import { ITransactionDistributionAPIResponse } from '../../../../server/lib/transactions/distribution';
import { TransactionListAPIResponse } from '../../../../server/lib/transactions/get_top_transactions';
import { MissingArgumentsError } from '../../../hooks/useFetcher';
import { IUrlParams } from '../../../store/urlParams';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { callApi } from '../callApi';
import { getEncodedEsQuery } from './apm';

View file

@ -1,18 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { rootReducer } from '../rootReducer';
describe('root reducer', () => {
it('should return the initial state', () => {
const state = rootReducer(undefined, {} as any);
expect(state).toEqual({
location: { hash: '', pathname: '', search: '' },
urlParams: {}
});
});
});

View file

@ -1,113 +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 { Location } from 'history';
import { mockNow } from '../../utils/testHelpers';
import { updateLocation } from '../location';
import { APMAction, refreshTimeRange, urlParamsReducer } from '../urlParams';
describe('urlParams', () => {
beforeEach(() => {
mockNow('2010');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should parse "last 15 minutes"', () => {
const action = updateLocation({
pathname: '',
search: '?rangeFrom=now-15m&rangeTo=now'
} as Location) as APMAction;
const { start, end } = urlParamsReducer({}, action);
expect({ start, end }).toEqual({
start: '2009-12-31T23:45:00.000Z',
end: '2010-01-01T00:00:00.000Z'
});
});
it('should parse "last 7 days"', () => {
const action = updateLocation({
pathname: '',
search: '?rangeFrom=now-7d&rangeTo=now'
} as Location) as APMAction;
const { start, end } = urlParamsReducer({}, action);
expect({ start, end }).toEqual({
start: '2009-12-25T00:00:00.000Z',
end: '2010-01-01T00:00:00.000Z'
});
});
it('should parse absolute dates', () => {
const action = updateLocation({
pathname: '',
search:
'?rangeFrom=2019-02-03T10:00:00.000Z&rangeTo=2019-02-10T16:30:00.000Z'
} as Location) as APMAction;
const { start, end } = urlParamsReducer({}, action);
expect({ start, end }).toEqual({
start: '2019-02-03T10:00:00.000Z',
end: '2019-02-10T16:30:00.000Z'
});
});
it('should handle LOCATION_UPDATE for transactions section', () => {
const action = updateLocation({
pathname:
'myServiceName/transactions/myTransactionType/myTransactionName/b/c',
search: '?transactionId=myTransactionId&detailTab=request&spanId=10'
} as Location) as APMAction;
const state = urlParamsReducer({}, action);
expect(state).toEqual({
detailTab: 'request',
end: '2010-01-01T00:00:00.000Z',
page: 0,
processorEvent: 'transaction',
rangeFrom: 'now-24h',
rangeTo: 'now',
refreshInterval: 0,
refreshPaused: true,
serviceName: 'myServiceName',
spanId: 10,
start: '2009-12-31T00:00:00.000Z',
transactionId: 'myTransactionId',
transactionName: 'myTransactionName',
transactionType: 'myTransactionType'
});
});
it('should handle LOCATION_UPDATE for error section', () => {
const action = updateLocation({
pathname: 'myServiceName/errors/myErrorGroupId',
search: '?detailTab=request&transactionId=myTransactionId'
} as Location) as APMAction;
const state = urlParamsReducer({}, action);
expect(state).toEqual(
expect.objectContaining({
serviceName: 'myServiceName',
errorGroupId: 'myErrorGroupId',
detailTab: 'request',
transactionId: 'myTransactionId'
})
);
});
it('should handle refreshTimeRange action', () => {
const action = refreshTimeRange({ rangeFrom: 'now', rangeTo: 'now-15m' });
const state = urlParamsReducer({}, action);
expect(state).toEqual({
start: '2010-01-01T00:00:00.000Z',
end: '2009-12-31T23:45:00.000Z'
});
});
});

View file

@ -1,21 +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 { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import throttle from '../middleware/throttle';
import { rootReducer } from '../rootReducer';
export default function configureStore(preloadedState) {
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
return createStore(
rootReducer,
preloadedState,
composeEnhancers(applyMiddleware(throttle, thunk))
);
}

View file

@ -1,13 +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 { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from '../rootReducer';
export default function configureStore(preloadedState) {
return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}

View file

@ -1,29 +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 { Location } from 'history';
import { AnyAction } from 'redux';
export const LOCATION_UPDATE = 'LOCATION_UPDATE';
export function locationReducer(
state = { pathname: '', search: '', hash: '' },
action: AnyAction
) {
switch (action.type) {
case LOCATION_UPDATE:
return action.location;
default:
return state;
}
}
export function updateLocation(nextLocation: Location) {
return {
type: LOCATION_UPDATE,
location: nextLocation
};
}

View file

@ -1,40 +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.
*/
// Throttle middleware for redux
// Should only be used during development
//
// tldr: While developing, it is easy to make minor mistakes that results in infinite dispatch loops.
// Longer Motivation: Infinite dispatch loop occurs, if a component dispatches an action, that in turn re-renders the component, which then again dispatches.
// Normally this is guarded, but if the condition is missing/erroneous an infinite loop happens.
// The root cause is mostly very simple to fix (update an if statement) but the infinite loop causes the browser to be unresponsive
// and only by killing and restarting the process can development continue.
// Solution: Block actions if more than x dispatches happens within y seconds
const MAX_DISPATCHES = 50;
const INTERVAL_MS = 2000;
let IS_THROTTLED = false;
let count = 0;
setInterval(() => {
count = 0;
}, INTERVAL_MS);
function throttle() {
return next => action => {
count += 1;
if (count > MAX_DISPATCHES || IS_THROTTLED) {
IS_THROTTLED = true;
console.error('Action was throttled', action);
return {};
}
return next(action);
};
}
export default throttle;

View file

@ -1,20 +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 { Location } from 'history';
import { combineReducers } from 'redux';
import { locationReducer } from './location';
import { IUrlParams, urlParamsReducer } from './urlParams';
export interface IReduxState {
location: Location;
urlParams: IUrlParams;
}
export const rootReducer = combineReducers({
location: locationReducer,
urlParams: urlParamsReducer
});

View file

@ -1,229 +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 datemath from '@elastic/datemath';
import { Location } from 'history';
import { compact, pick } from 'lodash';
import {
legacyDecodeURIComponent,
toQuery
} from '../components/shared/Links/url_helpers';
import { LOCATION_UPDATE } from './location';
import { IReduxState } from './rootReducer';
// ACTION TYPES
export const TIME_RANGE_REFRESH = 'TIME_RANGE_REFRESH';
export const TIMEPICKER_DEFAULTS = {
rangeFrom: 'now-24h',
rangeTo: 'now',
refreshPaused: 'true',
refreshInterval: '0'
};
interface TimeRange {
rangeFrom: string;
rangeTo: string;
}
interface LocationAction {
type: typeof LOCATION_UPDATE;
location: Location;
}
interface TimeRangeRefreshAction {
type: typeof TIME_RANGE_REFRESH;
time: TimeRange;
}
export type APMAction = LocationAction | TimeRangeRefreshAction;
function getParsedDate(rawDate?: string, opts = {}) {
if (rawDate) {
const parsed = datemath.parse(rawDate, opts);
if (parsed) {
return parsed.toISOString();
}
}
}
function getStart(prevState: IUrlParams, rangeFrom?: string) {
if (prevState.rangeFrom !== rangeFrom) {
return getParsedDate(rangeFrom);
}
return prevState.start;
}
function getEnd(prevState: IUrlParams, rangeTo?: string) {
if (prevState.rangeTo !== rangeTo) {
return getParsedDate(rangeTo, { roundUp: true });
}
return prevState.end;
}
// "urlParams" contains path and query parameters from the url, that can be easily consumed from
// any (container) component with access to the store
// Example:
// url: /opbeans-backend/Brewing%20Bot?transactionId=1321
// serviceName: opbeans-backend (path param)
// transactionType: Brewing%20Bot (path param)
// transactionId: 1321 (query param)
export function urlParamsReducer(
state: IUrlParams = {},
action: APMAction
): IUrlParams {
switch (action.type) {
case LOCATION_UPDATE: {
const {
processorEvent,
serviceName,
transactionName,
transactionType,
errorGroupId
} = getPathParams(action.location.pathname);
const {
traceId,
transactionId,
detailTab,
flyoutDetailTab,
waterfallItemId,
spanId,
page,
sortDirection,
sortField,
kuery,
refreshPaused = TIMEPICKER_DEFAULTS.refreshPaused,
refreshInterval = TIMEPICKER_DEFAULTS.refreshInterval,
rangeFrom = TIMEPICKER_DEFAULTS.rangeFrom,
rangeTo = TIMEPICKER_DEFAULTS.rangeTo
} = toQuery(action.location.search);
return removeUndefinedProps({
...state,
// date params
start: getStart(state, rangeFrom),
end: getEnd(state, rangeTo),
rangeFrom,
rangeTo,
refreshPaused: toBoolean(refreshPaused),
refreshInterval: toNumber(refreshInterval),
// query params
sortDirection,
sortField,
page: toNumber(page) || 0,
transactionId: toString(transactionId),
traceId: toString(traceId),
waterfallItemId: toString(waterfallItemId),
detailTab: toString(detailTab),
flyoutDetailTab: toString(flyoutDetailTab),
spanId: toNumber(spanId),
kuery: legacyDecodeURIComponent(kuery),
// path params
processorEvent,
serviceName,
transactionType: legacyDecodeURIComponent(transactionType),
transactionName: legacyDecodeURIComponent(transactionName),
errorGroupId
});
}
case TIME_RANGE_REFRESH:
return {
...state,
start: getParsedDate(action.time.rangeFrom),
end: getParsedDate(action.time.rangeTo)
};
default:
return state;
}
}
export function toNumber(value?: string) {
if (value !== undefined) {
return parseInt(value, 10);
}
}
function toString(value?: string) {
if (value === '' || value === 'null' || value === 'undefined') {
return;
}
return value;
}
export function toBoolean(value?: string) {
return value === 'true';
}
function getPathAsArray(pathname: string) {
return compact(pathname.split('/'));
}
function removeUndefinedProps<T>(obj: T): Partial<T> {
return pick(obj, value => value !== undefined);
}
function getPathParams(pathname: string) {
const paths = getPathAsArray(pathname);
const pageName = paths[1];
switch (pageName) {
case 'transactions':
return {
processorEvent: 'transaction',
serviceName: paths[0],
transactionType: paths[2],
transactionName: paths[3]
};
case 'errors':
return {
processorEvent: 'error',
serviceName: paths[0],
errorGroupId: paths[2]
};
case 'metrics':
return {
processorEvent: 'metric',
serviceName: paths[0]
};
default:
return {};
}
}
// ACTION CREATORS
export function refreshTimeRange(time: TimeRange): TimeRangeRefreshAction {
return { type: TIME_RANGE_REFRESH, time };
}
// Selectors
export function getUrlParams(state: IReduxState) {
return state.urlParams;
}
export interface IUrlParams {
detailTab?: string;
end?: string;
errorGroupId?: string;
flyoutDetailTab?: string;
kuery?: string;
rangeFrom?: string;
rangeTo?: string;
refreshInterval?: number;
refreshPaused?: boolean;
serviceName?: string;
sortDirection?: string;
sortField?: string;
start?: string;
traceId?: string;
transactionId?: string;
transactionName?: string;
transactionType?: string;
waterfallItemId?: string;
}

View file

@ -0,0 +1,13 @@
/*
* 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';
// Make history singleton available across APM project
// TODO: Explore using React context or hook instead?
const history = createHistory();
export { history };