mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[APM] ML integration (#19791)
This commit is contained in:
parent
ea0395fd1e
commit
90528194da
78 changed files with 7734 additions and 2343 deletions
41
x-pack/plugins/__mocks__/ui/chrome.js
Normal file
41
x-pack/plugins/__mocks__/ui/chrome.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function addBasePath(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
function getInjected(key) {
|
||||
switch (key) {
|
||||
case 'apmIndexPattern':
|
||||
return 'apm*';
|
||||
case 'mlEnabled':
|
||||
return true;
|
||||
default:
|
||||
throw new Error(`Unexpected config key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getInjected,
|
||||
addBasePath,
|
||||
getUiSettingsClient
|
||||
};
|
|
@ -28,6 +28,7 @@ export function apm(kibana) {
|
|||
injectDefaultVars(server) {
|
||||
const config = server.config();
|
||||
return {
|
||||
mlEnabled: config.get('xpack.ml.enabled'),
|
||||
apmUiEnabled: config.get('xpack.apm.ui.enabled'),
|
||||
apmIndexPattern: config.get('xpack.apm.indexPattern')
|
||||
};
|
||||
|
|
11
x-pack/plugins/apm/jsconfig.json
Normal file
11
x-pack/plugins/apm/jsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"baseUrl": "../../../.",
|
||||
"paths": {
|
||||
"ui/*": ["src/ui/public/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "**/node_modules/*", "build"]
|
||||
}
|
|
@ -1,87 +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, { Fragment, Component } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiPopover
|
||||
} from '@elastic/eui';
|
||||
import chrome from 'ui/chrome';
|
||||
|
||||
export default class OpenWatcherDialogButton extends Component {
|
||||
state = {
|
||||
isPopoverOpen: false
|
||||
};
|
||||
|
||||
onButtonClick = () => {
|
||||
this.setState(prevState => ({
|
||||
isPopoverOpen: !prevState.isPopoverOpen
|
||||
}));
|
||||
};
|
||||
|
||||
closePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: false
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const watcherButton = (
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Watcher
|
||||
</EuiButton>
|
||||
);
|
||||
|
||||
const items = [
|
||||
<EuiContextMenuItem
|
||||
key="create"
|
||||
icon="plusInCircle"
|
||||
onClick={() => {
|
||||
this.closePopover();
|
||||
this.props.onOpenFlyout();
|
||||
}}
|
||||
>
|
||||
Create new watch
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
key="view"
|
||||
icon="tableOfContents"
|
||||
onClick={() => {
|
||||
window.location = chrome.addBasePath(
|
||||
'/app/kibana#/management/elasticsearch/watcher/'
|
||||
);
|
||||
}}
|
||||
>
|
||||
View existing watches
|
||||
</EuiContextMenuItem>
|
||||
];
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiPopover
|
||||
id="watcher"
|
||||
button={watcherButton}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
ownFocus
|
||||
>
|
||||
<div style={{ width: '210px' }}>
|
||||
<EuiContextMenuPanel items={items} />
|
||||
</div>
|
||||
</EuiPopover>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { EuiButton, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui';
|
||||
|
||||
export default class WatcherButton extends Component {
|
||||
state = {
|
||||
isPopoverOpen: false
|
||||
};
|
||||
|
||||
onButtonClick = () => this.setState({ isPopoverOpen: true });
|
||||
closePopover = () => this.setState({ isPopoverOpen: false });
|
||||
|
||||
popOverPanels = [
|
||||
{
|
||||
id: 0,
|
||||
title: 'Watcher',
|
||||
items: [
|
||||
{
|
||||
name: 'Enable error reports',
|
||||
icon: <EuiIcon type="plusInCircle" size="m" />,
|
||||
onClick: () => {
|
||||
this.closePopover();
|
||||
this.props.onOpenFlyout();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'View existing watches',
|
||||
icon: 'tableOfContents',
|
||||
href: '/app/kibana#/management/elasticsearch/watcher/',
|
||||
target: '_blank',
|
||||
onClick: () => this.closePopover()
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
button = (
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Integrations
|
||||
</EuiButton>
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EuiPopover
|
||||
button={this.button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
<EuiContextMenu initialPanelId={0} panels={this.popOverPanels} />
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@ import _ from 'lodash';
|
|||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
|
@ -203,7 +202,6 @@ export default class WatcherFlyout extends Component {
|
|||
<KibanaLink
|
||||
pathname={'/app/kibana'}
|
||||
hash={`/management/elasticsearch/watcher/watches/watch/${id}`}
|
||||
query={{}}
|
||||
>
|
||||
View watch.
|
||||
</KibanaLink>
|
||||
|
@ -242,7 +240,7 @@ export default class WatcherFlyout extends Component {
|
|||
return { value: `${hour}:00`, text: `${hour}:00 UTC` };
|
||||
});
|
||||
|
||||
const flyoutContent = (
|
||||
const flyoutBody = (
|
||||
<EuiText>
|
||||
<p>
|
||||
This form will assist in creating a Watch that can notify you of error
|
||||
|
@ -420,33 +418,23 @@ export default class WatcherFlyout extends Component {
|
|||
<EuiFlyout onClose={this.props.onClose} size="s">
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2>Create new watch assistant</h2>
|
||||
<h2>Enable error reports</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>{flyoutContent}</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
onClick={this.props.onClose}
|
||||
flush="left"
|
||||
>
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={this.createWatch}
|
||||
fill
|
||||
disabled={
|
||||
!this.state.actions.email && !this.state.actions.slack
|
||||
}
|
||||
>
|
||||
Create watch
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlyoutBody>{flyoutBody}</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter
|
||||
style={{
|
||||
flexDirection: 'row-reverse',
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={this.createWatch}
|
||||
fill
|
||||
disabled={!this.state.actions.email && !this.state.actions.slack}
|
||||
>
|
||||
Create watch
|
||||
</EuiButton>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { createErrorGroupWatch } from '../createErrorGroupWatch';
|
||||
import mustache from 'mustache';
|
||||
import chrome from 'ui/chrome';
|
||||
import * as rest from '../../../../../services/rest';
|
||||
import * as rest from '../../../../../services/rest/watcher';
|
||||
import { isObject, isArray, isString } from 'lodash';
|
||||
import esResponse from './esResponse.json';
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
ERROR_EXC_HANDLED,
|
||||
ERROR_CULPRIT
|
||||
} from '../../../../../common/constants';
|
||||
import { createWatch } from '../../../../services/rest';
|
||||
import { createWatch } from '../../../../services/rest/watcher';
|
||||
|
||||
function getSlackPathUrl(slackUrl) {
|
||||
if (slackUrl) {
|
||||
|
|
|
@ -10,7 +10,7 @@ import { HeaderContainer } from '../../shared/UIComponents';
|
|||
import TabNavigation from '../../shared/TabNavigation';
|
||||
import List from './List';
|
||||
import WatcherFlyout from './Watcher/WatcherFlyOut';
|
||||
import OpenWatcherDialogButton from './Watcher/OpenWatcherDialogButton';
|
||||
import WatcherButton from './Watcher/WatcherButton';
|
||||
import { ErrorGroupDetailsRequest } from '../../../store/reactReduxRequest/errorGroupList';
|
||||
|
||||
class ErrorGroupOverview extends Component {
|
||||
|
@ -35,7 +35,7 @@ class ErrorGroupOverview extends Component {
|
|||
<HeaderContainer>
|
||||
<h1>{serviceName}</h1>
|
||||
{license.data.features.watcher.isAvailable && (
|
||||
<OpenWatcherDialogButton onOpenFlyout={this.onOpenFlyout} />
|
||||
<WatcherButton onOpenFlyout={this.onOpenFlyout} />
|
||||
)}
|
||||
</HeaderContainer>
|
||||
|
||||
|
@ -59,7 +59,9 @@ class ErrorGroupOverview extends Component {
|
|||
}
|
||||
|
||||
ErrorGroupOverview.propTypes = {
|
||||
location: PropTypes.object.isRequired
|
||||
license: PropTypes.object.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
urlParams: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default ErrorGroupOverview;
|
||||
|
|
|
@ -30,8 +30,8 @@ class Breadcrumbs extends React.Component {
|
|||
{isLast ? (
|
||||
<span
|
||||
ref={node => {
|
||||
if (node && document.title !== node.innerText) {
|
||||
document.title = capitalize(node.innerText);
|
||||
if (node && document.title !== node.textContent) {
|
||||
document.title = capitalize(node.textContent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -4,31 +4,9 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
jest.mock(
|
||||
'ui/chrome',
|
||||
() => ({
|
||||
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 }
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
jest.mock('../../../../utils/timepicker', () => {});
|
||||
|
||||
import Breadcrumbs from '../Breadcrumbs';
|
||||
import { toJson } from '../../../../utils/testHelpers';
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import React, { Component } from 'react';
|
||||
import { STATUS } from '../../../constants';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { loadAgentStatus } from '../../../services/rest';
|
||||
import { loadAgentStatus } from '../../../services/rest/apm';
|
||||
import { KibanaLink } from '../../../utils/url';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import List from './List';
|
||||
|
@ -75,7 +75,7 @@ class ServiceOverview extends Component {
|
|||
|
||||
function SetupInstructionsLink({ buttonFill = false }) {
|
||||
return (
|
||||
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'} query={{}}>
|
||||
<KibanaLink pathname={'/app/kibana'} hash={'/home/tutorial/apm'}>
|
||||
<EuiButton size="s" color="primary" fill={buttonFill}>
|
||||
Setup Instructions
|
||||
</EuiButton>
|
||||
|
|
|
@ -33,6 +33,13 @@ const Heading = styled.div`
|
|||
|
||||
const Legends = styled.div`
|
||||
display: flex;
|
||||
|
||||
div {
|
||||
margin-right: ${px(unit)};
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function TimelineHeader({ legends, transactionName }) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { getUrlParams } from '../../../store/urlParams';
|
|||
|
||||
function mapStateToProps(state = {}) {
|
||||
return {
|
||||
location: state.location,
|
||||
urlParams: getUrlParams(state)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -13,14 +13,16 @@ import Charts from '../../shared/charts/TransactionCharts';
|
|||
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
|
||||
import { TransactionDetailsRequest } from '../../../store/reactReduxRequest/transactionDetails';
|
||||
|
||||
function TransactionDetails({ urlParams }) {
|
||||
function TransactionDetails({ urlParams, location }) {
|
||||
return (
|
||||
<div>
|
||||
<HeaderLarge>{urlParams.transactionName}</HeaderLarge>
|
||||
|
||||
<DetailsChartsRequest
|
||||
urlParams={urlParams}
|
||||
render={({ data }) => <Charts charts={data} urlParams={urlParams} />}
|
||||
render={({ data }) => (
|
||||
<Charts charts={data} urlParams={urlParams} location={location} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<TransactionDistributionRequest
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { EuiButton, EuiPopover, EuiIcon, EuiContextMenu } from '@elastic/eui';
|
||||
|
||||
export default class DynamicBaselineButton extends Component {
|
||||
state = {
|
||||
isPopoverOpen: false
|
||||
};
|
||||
|
||||
onButtonClick = () => this.setState({ isPopoverOpen: true });
|
||||
closePopover = () => this.setState({ isPopoverOpen: false });
|
||||
|
||||
popOverPanels = [
|
||||
{
|
||||
id: 0,
|
||||
title: 'Machine Learning',
|
||||
items: [
|
||||
{
|
||||
name: 'Anomaly detection (BETA)',
|
||||
icon: <EuiIcon type="stats" size="m" />,
|
||||
onClick: () => {
|
||||
this.closePopover();
|
||||
this.props.onOpenFlyout();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'View existing jobs',
|
||||
icon: 'tableOfContents',
|
||||
href: '/app/ml',
|
||||
target: '_blank',
|
||||
onClick: () => this.closePopover()
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
button = (
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
Integrations
|
||||
</EuiButton>
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EuiPopover
|
||||
button={this.button}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
<EuiContextMenu initialPanelId={0} panels={this.popOverPanels} />
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* 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 { startMlJob } from '../../../../services/rest/ml';
|
||||
import { getAPMIndexPattern } from '../../../../services/rest/savedObjects';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiCallOut,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiGlobalToastList,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiBetaBadge
|
||||
} from '@elastic/eui';
|
||||
import { getMlJobUrl } from '../../../../utils/url';
|
||||
|
||||
export default class DynamicBaselineFlyout extends Component {
|
||||
state = {
|
||||
toasts: [],
|
||||
isLoading: false,
|
||||
hasIndexPattern: null
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
getAPMIndexPattern().then(indexPattern => {
|
||||
this.setState({ hasIndexPattern: indexPattern != null });
|
||||
});
|
||||
}
|
||||
|
||||
createDynamicBaseline = async () => {
|
||||
this.setState({ isLoading: true });
|
||||
try {
|
||||
const { serviceName, transactionType } = this.props;
|
||||
if (serviceName && transactionType) {
|
||||
const res = await startMlJob({ serviceName, transactionType });
|
||||
const didSucceed = res.datafeeds[0].success && res.jobs[0].success;
|
||||
if (!didSucceed) {
|
||||
throw new Error('Creating dynamic baseline failed');
|
||||
}
|
||||
this.addSuccessToast();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.addErrorToast();
|
||||
}
|
||||
|
||||
this.setState({ isLoading: false });
|
||||
this.props.onClose();
|
||||
};
|
||||
|
||||
addErrorToast = () => {
|
||||
const { serviceName, transactionType, location } = this.props;
|
||||
this.setState({
|
||||
toasts: [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Job already exists',
|
||||
color: 'warning',
|
||||
text: (
|
||||
<p>
|
||||
There's already a job running for anomaly detection on{' '}
|
||||
{serviceName} ({transactionType}).{' '}
|
||||
<a href={getMlJobUrl(serviceName, transactionType, location)}>
|
||||
View existing job
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
addSuccessToast = () => {
|
||||
const { serviceName, transactionType, location } = this.props;
|
||||
this.setState({
|
||||
toasts: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Job successfully created',
|
||||
color: 'success',
|
||||
text: (
|
||||
<p>
|
||||
The analysis is now running for {serviceName} ({transactionType}).
|
||||
It might take a while before results are added to the response
|
||||
times graph.{' '}
|
||||
<a href={getMlJobUrl(serviceName, transactionType, location)}>
|
||||
View job
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
removeToasts = () => {
|
||||
this.setState({
|
||||
toasts: []
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
hasDynamicBaseline,
|
||||
isOpen,
|
||||
location,
|
||||
onClose,
|
||||
serviceName,
|
||||
transactionType
|
||||
} = this.props;
|
||||
const { isLoading, hasIndexPattern, toasts } = this.state;
|
||||
|
||||
const flyout = (
|
||||
<EuiFlyout onClose={onClose} size="s">
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle>
|
||||
<h2>Enable anomaly detection on response times</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiBetaBadge
|
||||
label="Beta"
|
||||
tooltipContent="This feature is currently in beta. Please help us by reporting any bugs."
|
||||
/>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
{hasDynamicBaseline && (
|
||||
<div>
|
||||
<EuiCallOut
|
||||
title={<span>Job already exists</span>}
|
||||
color="success"
|
||||
iconType="check"
|
||||
>
|
||||
<p>
|
||||
There is currently a job running for {serviceName} ({
|
||||
transactionType
|
||||
}).{' '}
|
||||
<a href={getMlJobUrl(serviceName, transactionType, location)}>
|
||||
View existing job
|
||||
</a>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="m" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasIndexPattern && (
|
||||
<div>
|
||||
<EuiCallOut
|
||||
title={
|
||||
<span>
|
||||
No APM index pattern available. To create a job, please
|
||||
import the APM index pattern via the{' '}
|
||||
<a href="/app/kibana#/home/tutorial/apm">
|
||||
Setup Instructions
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
color="warning"
|
||||
iconType="alert"
|
||||
/>
|
||||
<EuiSpacer size="m" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EuiText>
|
||||
<p>
|
||||
This integration will start a new Machine Learning job that is
|
||||
predefined to calculate anomaly scores on response times on APM
|
||||
transactions. Once enabled, the response time graph will show the
|
||||
expected bounds from the Machine Learning job and annotate the
|
||||
graph once the anomaly score is >=75.
|
||||
</p>
|
||||
<img
|
||||
src="/plugins/apm/images/apm-ml-anomaly-detection-example.png"
|
||||
alt="Anomaly detection on response times in APM"
|
||||
/>
|
||||
<p>
|
||||
Jobs can be created per transaction type and based on the average
|
||||
response time. Once a job is created, you can manage it and see
|
||||
more details in the{' '}
|
||||
<a href="/app/ml">Machine Learning jobs management page</a>. It
|
||||
might take some time for the job to calculate the results. Please
|
||||
refresh the graph a few minutes after creating the job.
|
||||
</p>
|
||||
<p>
|
||||
<a href="#">Learn more</a> about the Machine Learning integration.
|
||||
</p>
|
||||
</EuiText>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter
|
||||
style={{
|
||||
flexDirection: 'row-reverse',
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<EuiButton
|
||||
onClick={this.createDynamicBaseline}
|
||||
fill
|
||||
disabled={isLoading || hasDynamicBaseline || !hasIndexPattern}
|
||||
>
|
||||
Create new job
|
||||
</EuiButton>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{isOpen && flyout}
|
||||
<EuiGlobalToastList
|
||||
toasts={toasts}
|
||||
dismissToast={this.removeToasts}
|
||||
toastLifeTimeMs={5000}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DynamicBaselineFlyout.propTypes = {
|
||||
hasDynamicBaseline: PropTypes.bool.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
serviceName: PropTypes.string,
|
||||
transactionType: PropTypes.string
|
||||
};
|
|
@ -4,35 +4,24 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
jest.mock(
|
||||
'ui/chrome',
|
||||
() => ({
|
||||
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 }
|
||||
);
|
||||
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import TransactionOverview from '../view';
|
||||
import { toJson } from '../../../../utils/testHelpers';
|
||||
jest.mock('../../../../utils/timepicker', () => {});
|
||||
|
||||
const setup = () => {
|
||||
const props = {
|
||||
changeTransactionSorting: () => {},
|
||||
license: {
|
||||
data: {
|
||||
features: {
|
||||
ml: { isAvailable: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
transactionSorting: {},
|
||||
hasDynamicBaseline: false,
|
||||
location: {},
|
||||
urlParams: { transactionType: 'request', serviceName: 'MyServiceName' }
|
||||
};
|
||||
|
||||
|
|
|
@ -2,9 +2,22 @@
|
|||
|
||||
exports[`TransactionOverview should not call loadTransactionList without any props 1`] = `
|
||||
<div>
|
||||
<styled.h1>
|
||||
MyServiceName
|
||||
</styled.h1>
|
||||
<styled.div>
|
||||
<h1>
|
||||
MyServiceName
|
||||
</h1>
|
||||
<DynamicBaselineButton
|
||||
onOpenFlyout={[Function]}
|
||||
/>
|
||||
</styled.div>
|
||||
<DynamicBaselineFlyout
|
||||
hasDynamicBaseline={false}
|
||||
isOpen={false}
|
||||
location={Object {}}
|
||||
onClose={[Function]}
|
||||
serviceName="MyServiceName"
|
||||
transactionType="request"
|
||||
/>
|
||||
<Connect(TabNavigation) />
|
||||
<OverviewChartsRequest
|
||||
render={[Function]}
|
||||
|
|
|
@ -8,12 +8,16 @@ import { connect } from 'react-redux';
|
|||
import TransactionOverview from './view';
|
||||
import { getUrlParams } from '../../../store/urlParams';
|
||||
import sorting, { changeTransactionSorting } from '../../../store/sorting';
|
||||
import { hasDynamicBaseline } from '../../../store/reactReduxRequest/overviewCharts';
|
||||
import { getLicense } from '../../../store/reactReduxRequest/license';
|
||||
|
||||
function mapStateToProps(state = {}) {
|
||||
return {
|
||||
urlParams: getUrlParams(state),
|
||||
|
||||
transactionSorting: sorting(state, 'transaction').sorting.transaction
|
||||
hasDynamicBaseline: hasDynamicBaseline(state),
|
||||
location: state.location,
|
||||
transactionSorting: sorting(state, 'transaction').sorting.transaction,
|
||||
license: getLicense(state)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -4,16 +4,24 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiIconTip } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import chrome from 'ui/chrome';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { HeaderLarge, HeaderMedium } from '../../shared/UIComponents';
|
||||
import { HeaderContainer, HeaderMedium } from '../../shared/UIComponents';
|
||||
import TabNavigation from '../../shared/TabNavigation';
|
||||
import Charts from '../../shared/charts/TransactionCharts';
|
||||
import { getMlJobUrl } from '../../../utils/url';
|
||||
import List from './List';
|
||||
import { units, px, fontSizes } from '../../../style/variables';
|
||||
import { OverviewChartsRequest } from '../../../store/reactReduxRequest/overviewCharts';
|
||||
import { TransactionListRequest } from '../../../store/reactReduxRequest/transactionList';
|
||||
import { ServiceDetailsRequest } from '../../../store/reactReduxRequest/serviceDetails';
|
||||
|
||||
import DynamicBaselineButton from './DynamicBaseline/Button';
|
||||
import DynamicBaselineFlyout from './DynamicBaseline/Flyout';
|
||||
|
||||
function ServiceDetailsAndTransactionList({ urlParams, render }) {
|
||||
return (
|
||||
<ServiceDetailsRequest
|
||||
|
@ -35,46 +43,117 @@ function ServiceDetailsAndTransactionList({ urlParams, render }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function TransactionOverview({
|
||||
changeTransactionSorting,
|
||||
transactionSorting,
|
||||
urlParams
|
||||
}) {
|
||||
const { serviceName, transactionType } = urlParams;
|
||||
const MLTipContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${fontSizes.small};
|
||||
`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HeaderLarge>{serviceName}</HeaderLarge>
|
||||
const MLText = styled.div`
|
||||
margin-left: ${px(units.half)};
|
||||
`;
|
||||
|
||||
<TabNavigation />
|
||||
class TransactionOverview extends Component {
|
||||
state = {
|
||||
isFlyoutOpen: false
|
||||
};
|
||||
|
||||
<OverviewChartsRequest
|
||||
urlParams={urlParams}
|
||||
render={({ data }) => <Charts charts={data} urlParams={urlParams} />}
|
||||
/>
|
||||
onOpenFlyout = () => this.setState({ isFlyoutOpen: true });
|
||||
onCloseFlyout = () => this.setState({ isFlyoutOpen: false });
|
||||
|
||||
<HeaderMedium>{transactionTypeLabel(transactionType)}</HeaderMedium>
|
||||
render() {
|
||||
const {
|
||||
changeTransactionSorting,
|
||||
hasDynamicBaseline,
|
||||
license,
|
||||
location,
|
||||
transactionSorting,
|
||||
urlParams
|
||||
} = this.props;
|
||||
|
||||
<ServiceDetailsAndTransactionList
|
||||
urlParams={urlParams}
|
||||
render={({ serviceDetails, transactionList }) => {
|
||||
return (
|
||||
<List
|
||||
agentName={serviceDetails.agentName}
|
||||
serviceName={serviceName}
|
||||
type={transactionType}
|
||||
items={transactionList}
|
||||
changeTransactionSorting={changeTransactionSorting}
|
||||
transactionSorting={transactionSorting}
|
||||
const { serviceName, transactionType } = urlParams;
|
||||
const mlEnabled = chrome.getInjected('mlEnabled');
|
||||
|
||||
const ChartHeaderContent =
|
||||
hasDynamicBaseline && license.data.features.ml.isAvailable ? (
|
||||
<MLTipContainer>
|
||||
<EuiIconTip content="The stream around the average response time shows the expected bounds. An annotation is shown for anomaly scores >= 75." />
|
||||
<MLText>
|
||||
Machine Learning:{' '}
|
||||
<a
|
||||
href={getMlJobUrl(
|
||||
serviceName,
|
||||
transactionType,
|
||||
this.props.location
|
||||
)}
|
||||
>
|
||||
View Job
|
||||
</a>
|
||||
</MLText>
|
||||
</MLTipContainer>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HeaderContainer>
|
||||
<h1>{serviceName}</h1>
|
||||
{license.data.features.ml.isAvailable &&
|
||||
mlEnabled && (
|
||||
<DynamicBaselineButton onOpenFlyout={this.onOpenFlyout} />
|
||||
)}
|
||||
</HeaderContainer>
|
||||
|
||||
<DynamicBaselineFlyout
|
||||
hasDynamicBaseline={hasDynamicBaseline}
|
||||
isOpen={this.state.isFlyoutOpen}
|
||||
location={location}
|
||||
onClose={this.onCloseFlyout}
|
||||
serviceName={serviceName}
|
||||
transactionType={transactionType}
|
||||
/>
|
||||
|
||||
<TabNavigation />
|
||||
|
||||
<OverviewChartsRequest
|
||||
urlParams={urlParams}
|
||||
render={({ data }) => (
|
||||
<Charts
|
||||
charts={data}
|
||||
urlParams={urlParams}
|
||||
location={location}
|
||||
ChartHeaderContent={ChartHeaderContent}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
/>
|
||||
|
||||
<HeaderMedium>{transactionTypeLabel(transactionType)}</HeaderMedium>
|
||||
|
||||
<ServiceDetailsAndTransactionList
|
||||
urlParams={urlParams}
|
||||
render={({ serviceDetails, transactionList }) => {
|
||||
return (
|
||||
<List
|
||||
agentName={serviceDetails.agentName}
|
||||
serviceName={serviceName}
|
||||
type={transactionType}
|
||||
items={transactionList}
|
||||
changeTransactionSorting={changeTransactionSorting}
|
||||
transactionSorting={transactionSorting}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransactionOverview.propTypes = {
|
||||
changeTransactionSorting: PropTypes.func.isRequired,
|
||||
hasDynamicBaseline: PropTypes.bool.isRequired,
|
||||
license: PropTypes.object.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
transactionSorting: PropTypes.object.isRequired,
|
||||
urlParams: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
|
@ -89,3 +168,5 @@ function transactionTypeLabel(type) {
|
|||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export default TransactionOverview;
|
||||
|
|
|
@ -13,11 +13,8 @@ import {
|
|||
legacyEncodeURIComponent
|
||||
} from '../../../utils/url';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { EuiFieldSearch } from '@elastic/eui';
|
||||
|
||||
import { getAPMIndexPattern } from '../../../services/rest';
|
||||
|
||||
import { getAPMIndexPattern } from '../../../services/rest/savedObjects';
|
||||
import { convertKueryToEsQuery, getSuggestions } from '../../../services/kuery';
|
||||
import styled from 'styled-components';
|
||||
|
||||
|
|
|
@ -13,43 +13,43 @@ import SelectionMarker from './SelectionMarker';
|
|||
import { MarkSeries, VerticalGridLines } from 'react-vis';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
function getPointByX(serie, x) {
|
||||
return serie.data.find(point => point.x === x);
|
||||
}
|
||||
|
||||
class InteractivePlot extends PureComponent {
|
||||
getMarkPoints = hoverIndex => {
|
||||
if (!this.props.series[0].data[hoverIndex]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.props.series.map(serie => {
|
||||
const { x, y } = serie.data[hoverIndex] || {};
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
color: serie.color
|
||||
};
|
||||
});
|
||||
getMarkPoints = hoverX => {
|
||||
return this.props.series
|
||||
.filter(serie =>
|
||||
serie.data.some(point => point.x === hoverX && point.y != null)
|
||||
)
|
||||
.map(serie => {
|
||||
const { x, y } = getPointByX(serie, hoverX) || {};
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
color: serie.color
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
getTooltipPoints = hoverIndex => {
|
||||
if (!this.props.series[0].data[hoverIndex]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.props.series.map(serie => ({
|
||||
color: serie.color,
|
||||
value: this.props.formatTooltipValue(_.get(serie.data[hoverIndex], 'y')),
|
||||
text: serie.titleShort || serie.title
|
||||
}));
|
||||
};
|
||||
|
||||
getHoveredX = hoverIndex => {
|
||||
const defaultSerie = this.props.series[0].data;
|
||||
return _.get(defaultSerie[hoverIndex], 'x');
|
||||
getTooltipPoints = hoverX => {
|
||||
return this.props.series
|
||||
.filter(series => !series.hideTooltipValue)
|
||||
.map(serie => {
|
||||
const point = getPointByX(serie, hoverX) || {};
|
||||
return {
|
||||
color: serie.color,
|
||||
value: this.props.formatTooltipValue(point),
|
||||
text: serie.titleShort || serie.title
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
plotValues,
|
||||
hoverIndex,
|
||||
hoverX,
|
||||
series,
|
||||
isDrawing,
|
||||
selectionStart,
|
||||
|
@ -60,24 +60,19 @@ class InteractivePlot extends PureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
const tooltipPoints = this.getTooltipPoints(hoverIndex);
|
||||
const markPoints = this.getMarkPoints(hoverIndex);
|
||||
const hoveredX = this.getHoveredX(hoverIndex);
|
||||
const tooltipPoints = this.getTooltipPoints(hoverX);
|
||||
const markPoints = this.getMarkPoints(hoverX);
|
||||
const { x, yTickValues } = plotValues;
|
||||
const yValueMiddle = yTickValues[1];
|
||||
|
||||
return (
|
||||
<SharedPlot plotValues={plotValues}>
|
||||
{hoveredX && (
|
||||
<Tooltip
|
||||
tooltipPoints={tooltipPoints}
|
||||
x={hoveredX}
|
||||
y={yValueMiddle}
|
||||
/>
|
||||
{hoverX && (
|
||||
<Tooltip tooltipPoints={tooltipPoints} x={hoverX} y={yValueMiddle} />
|
||||
)}
|
||||
|
||||
{hoveredX && <MarkSeries data={markPoints} colorType="literal" />}
|
||||
{hoveredX && <VerticalGridLines tickValues={[hoveredX]} />}
|
||||
{hoverX && <MarkSeries data={markPoints} colorType="literal" />}
|
||||
{hoverX && <VerticalGridLines tickValues={[hoverX]} />}
|
||||
|
||||
{isDrawing &&
|
||||
selectionEnd !== null && (
|
||||
|
@ -90,7 +85,7 @@ class InteractivePlot extends PureComponent {
|
|||
|
||||
InteractivePlot.propTypes = {
|
||||
formatTooltipValue: PropTypes.func.isRequired,
|
||||
hoverIndex: PropTypes.number,
|
||||
hoverX: PropTypes.number,
|
||||
isDrawing: PropTypes.bool.isRequired,
|
||||
plotValues: PropTypes.object.isRequired,
|
||||
selectionEnd: PropTypes.number,
|
||||
|
|
|
@ -9,6 +9,7 @@ import React from 'react';
|
|||
import styled from 'styled-components';
|
||||
import Legend from '../Legend';
|
||||
import {
|
||||
unit,
|
||||
units,
|
||||
fontSizes,
|
||||
px,
|
||||
|
@ -20,6 +21,13 @@ const Container = styled.div`
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
div {
|
||||
margin-right: ${px(unit)};
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LegendContent = styled.span`
|
||||
|
@ -53,9 +61,9 @@ function MoreSeries({ hiddenSeriesCount }) {
|
|||
}
|
||||
|
||||
export default function Legends({
|
||||
noHits,
|
||||
clickLegend,
|
||||
hiddenSeriesCount,
|
||||
noHits,
|
||||
series,
|
||||
seriesEnabledState,
|
||||
truncateLegends
|
||||
|
@ -67,6 +75,9 @@ export default function Legends({
|
|||
return (
|
||||
<Container>
|
||||
{series.map((serie, i) => {
|
||||
if (serie.hideLegend) {
|
||||
return null;
|
||||
}
|
||||
const text = (
|
||||
<LegendContent>
|
||||
{truncateLegends ? (
|
||||
|
@ -95,7 +106,6 @@ export default function Legends({
|
|||
}
|
||||
|
||||
Legends.propTypes = {
|
||||
chartTitle: PropTypes.string,
|
||||
clickLegend: PropTypes.func.isRequired,
|
||||
hiddenSeriesCount: PropTypes.number.isRequired,
|
||||
noHits: PropTypes.bool.isRequired,
|
||||
|
|
|
@ -13,13 +13,21 @@ import {
|
|||
} from 'react-vis';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { last } from 'lodash';
|
||||
|
||||
import StatusText from './StatusText';
|
||||
import { SharedPlot } from './plotUtils';
|
||||
|
||||
const X_TICK_TOTAL = 7;
|
||||
class StaticPlot extends PureComponent {
|
||||
getSerie(serie) {
|
||||
getVisSeries(series, plotValues) {
|
||||
return series
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(serie => this.getSerie(serie, plotValues));
|
||||
}
|
||||
|
||||
getSerie(serie, plotValues) {
|
||||
switch (serie.type) {
|
||||
case 'line':
|
||||
return (
|
||||
|
@ -45,6 +53,27 @@ class StaticPlot extends PureComponent {
|
|||
fill={serie.areaColor}
|
||||
/>
|
||||
);
|
||||
case 'areaMaxHeight':
|
||||
const yMax = last(plotValues.yTickValues);
|
||||
const data = serie.data.map(p => ({
|
||||
x: p.x,
|
||||
y0: 0,
|
||||
y: p.y ? yMax : null
|
||||
}));
|
||||
|
||||
return (
|
||||
<AreaSeries
|
||||
getNull={d => d.y !== null}
|
||||
key={serie.title}
|
||||
xType="time"
|
||||
curve={'curveMonotoneX'}
|
||||
data={data}
|
||||
color={serie.color}
|
||||
stroke={serie.color}
|
||||
fill={serie.areaColor}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown type ${serie.type}`);
|
||||
}
|
||||
|
@ -63,10 +92,7 @@ class StaticPlot extends PureComponent {
|
|||
{noHits ? (
|
||||
<StatusText text="No data within this time range." />
|
||||
) : (
|
||||
series
|
||||
.slice()
|
||||
.reverse()
|
||||
.map(this.getSerie)
|
||||
this.getVisSeries(series, plotValues)
|
||||
)}
|
||||
</SharedPlot>
|
||||
);
|
||||
|
|
|
@ -11,12 +11,18 @@ import React, { PureComponent } from 'react';
|
|||
|
||||
import { SharedPlot } from './plotUtils';
|
||||
|
||||
function getXValuesCombined(series) {
|
||||
return _.union(...series.map(serie => serie.data.map(p => p.x))).map(x => ({
|
||||
x
|
||||
}));
|
||||
}
|
||||
|
||||
class VoronoiPlot extends PureComponent {
|
||||
render() {
|
||||
const { series, plotValues, noHits } = this.props;
|
||||
const { XY_MARGIN, XY_HEIGHT, XY_WIDTH, x } = plotValues;
|
||||
const defaultSerieData = _.get(series, '[0].data');
|
||||
if (!defaultSerieData || noHits) {
|
||||
const xValuesCombined = getXValuesCombined(series);
|
||||
if (!xValuesCombined || noHits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -27,7 +33,7 @@ class VoronoiPlot extends PureComponent {
|
|||
>
|
||||
<Voronoi
|
||||
extent={[[XY_MARGIN.left, XY_MARGIN.top], [XY_WIDTH, XY_HEIGHT]]}
|
||||
nodes={defaultSerieData}
|
||||
nodes={xValuesCombined}
|
||||
onHover={this.props.onHover}
|
||||
onMouseDown={this.props.onMouseDown}
|
||||
onMouseUp={this.props.onMouseUp}
|
||||
|
|
|
@ -8,7 +8,6 @@ import _ from 'lodash';
|
|||
import { makeWidthFlexible } from 'react-vis';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { PureComponent, Fragment } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import Legends from './Legends';
|
||||
import StaticPlot from './StaticPlot';
|
||||
|
@ -16,14 +15,12 @@ import InteractivePlot from './InteractivePlot';
|
|||
import VoronoiPlot from './VoronoiPlot';
|
||||
import { createSelector } from 'reselect';
|
||||
import { getPlotValues } from './plotUtils';
|
||||
import { fontSizes, units, px } from '../../../../style/variables';
|
||||
|
||||
const Title = styled.div`
|
||||
font-size: ${fontSizes.large};
|
||||
margin-bottom: ${px(units.half)};
|
||||
`;
|
||||
const VISIBLE_LEGEND_COUNT = 4;
|
||||
|
||||
const VISIBLE_SERIES_COUNT = 4;
|
||||
function getHiddenLegendCount(series) {
|
||||
return series.filter(serie => serie.hideLegend).length;
|
||||
}
|
||||
|
||||
export class InnerCustomPlot extends PureComponent {
|
||||
state = {
|
||||
|
@ -49,7 +46,12 @@ export class InnerCustomPlot extends PureComponent {
|
|||
|
||||
getVisibleSeries = createSelector(
|
||||
state => state.series,
|
||||
series => series.slice(0, VISIBLE_SERIES_COUNT)
|
||||
series => {
|
||||
return series.slice(
|
||||
0,
|
||||
VISIBLE_LEGEND_COUNT + getHiddenLegendCount(series)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
clickLegend = i => {
|
||||
|
@ -91,10 +93,7 @@ export class InnerCustomPlot extends PureComponent {
|
|||
};
|
||||
|
||||
onHover = node => {
|
||||
const index = this.props.series[0].data.findIndex(
|
||||
item => item.x === node.x
|
||||
);
|
||||
this.props.onHover(index);
|
||||
this.props.onHover(node.x);
|
||||
|
||||
if (this.state.isDrawing) {
|
||||
this.setState({ selectionEnd: node.x });
|
||||
|
@ -102,13 +101,16 @@ export class InnerCustomPlot extends PureComponent {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { chartTitle, series, truncateLegends, noHits, width } = this.props;
|
||||
const { series, truncateLegends, noHits, width } = this.props;
|
||||
|
||||
if (_.isEmpty(series) || !width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hiddenSeriesCount = Math.max(series.length - VISIBLE_SERIES_COUNT, 0);
|
||||
const hiddenSeriesCount = Math.max(
|
||||
series.length - VISIBLE_LEGEND_COUNT - getHiddenLegendCount(series),
|
||||
0
|
||||
);
|
||||
const visibleSeries = this.getVisibleSeries({ series });
|
||||
const enabledSeries = this.getEnabledSeries({
|
||||
visibleSeries,
|
||||
|
@ -126,11 +128,8 @@ export class InnerCustomPlot extends PureComponent {
|
|||
|
||||
return (
|
||||
<Fragment>
|
||||
<Title>{chartTitle}</Title>
|
||||
|
||||
<Legends
|
||||
noHits={noHits}
|
||||
chartTitle={chartTitle}
|
||||
truncateLegends={truncateLegends}
|
||||
series={visibleSeries}
|
||||
hiddenSeriesCount={hiddenSeriesCount}
|
||||
|
@ -149,7 +148,7 @@ export class InnerCustomPlot extends PureComponent {
|
|||
|
||||
<InteractivePlot
|
||||
plotValues={plotValues}
|
||||
hoverIndex={this.props.hoverIndex}
|
||||
hoverX={this.props.hoverX}
|
||||
series={enabledSeries}
|
||||
formatTooltipValue={this.props.formatTooltipValue}
|
||||
isDrawing={this.state.isDrawing}
|
||||
|
@ -174,7 +173,7 @@ export class InnerCustomPlot extends PureComponent {
|
|||
|
||||
InnerCustomPlot.propTypes = {
|
||||
formatTooltipValue: PropTypes.func,
|
||||
hoverIndex: PropTypes.number,
|
||||
hoverX: PropTypes.number,
|
||||
noHits: PropTypes.bool.isRequired,
|
||||
onHover: PropTypes.func.isRequired,
|
||||
onMouseLeave: PropTypes.func.isRequired,
|
||||
|
@ -186,7 +185,8 @@ InnerCustomPlot.propTypes = {
|
|||
};
|
||||
|
||||
InnerCustomPlot.defaultProps = {
|
||||
formatTooltipValue: y => y,
|
||||
formatTooltipValue: p => p.y,
|
||||
tickFormatX: undefined,
|
||||
tickFormatY: y => y,
|
||||
truncateLegends: false
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@ const XY_HEIGHT = unit * 16;
|
|||
const XY_MARGIN = {
|
||||
top: unit,
|
||||
left: unit * 5,
|
||||
right: unit,
|
||||
right: 0,
|
||||
bottom: unit * 2
|
||||
};
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ describe('when response has data', () => {
|
|||
|
||||
describe('Initially', () => {
|
||||
it('should have 3 enabled series', () => {
|
||||
expect(wrapper.find('AreaSeries').length).toBe(3);
|
||||
expect(wrapper.find('LineSeries').length).toBe(3);
|
||||
});
|
||||
|
||||
it('should have 3 legends ', () => {
|
||||
|
@ -96,7 +96,7 @@ describe('when response has data', () => {
|
|||
});
|
||||
|
||||
it('should have 2 enabled series', () => {
|
||||
expect(wrapper.find('AreaSeries').length).toBe(2);
|
||||
expect(wrapper.find('LineSeries').length).toBe(2);
|
||||
});
|
||||
|
||||
it('should add disabled prop to Legends', () => {
|
||||
|
@ -157,19 +157,20 @@ describe('when response has data', () => {
|
|||
});
|
||||
|
||||
describe('when hovering over', () => {
|
||||
const index = 22;
|
||||
beforeEach(() => {
|
||||
wrapper
|
||||
.find('.rv-voronoi__cell')
|
||||
.at(22)
|
||||
.at(index)
|
||||
.simulate('mouseOver');
|
||||
});
|
||||
|
||||
it('should call onHover', () => {
|
||||
expect(onHover).toHaveBeenCalledWith(22);
|
||||
expect(onHover).toHaveBeenCalledWith(responseWithData.dates[index]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setting hoverIndex', () => {
|
||||
describe('when setting hoverX', () => {
|
||||
beforeEach(() => {
|
||||
// Avoid timezone issues in snapshots
|
||||
jest.spyOn(moment.prototype, 'format').mockImplementation(function() {
|
||||
|
@ -177,9 +178,9 @@ describe('when response has data', () => {
|
|||
});
|
||||
|
||||
// Simulate hovering over multiple buckets
|
||||
wrapper.setProps({ hoverIndex: 13 });
|
||||
wrapper.setProps({ hoverIndex: 14 });
|
||||
wrapper.setProps({ hoverIndex: 15 });
|
||||
wrapper.setProps({ hoverX: responseWithData.dates[13] });
|
||||
wrapper.setProps({ hoverX: responseWithData.dates[14] });
|
||||
wrapper.setProps({ hoverX: responseWithData.dates[15] });
|
||||
});
|
||||
|
||||
it('should display tooltip', () => {
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -131,7 +131,8 @@
|
|||
2547299.079999993,
|
||||
4586742.89999998,
|
||||
0
|
||||
]
|
||||
],
|
||||
"avgAnomalies": {}
|
||||
},
|
||||
"tpmBuckets": [
|
||||
{
|
||||
|
@ -283,6 +284,6 @@
|
|||
]
|
||||
}
|
||||
],
|
||||
"weightedAverage": 467582.45401459857,
|
||||
"overallAvgDuration": 467582.45401459857,
|
||||
"totalHits": 999
|
||||
}
|
||||
|
|
|
@ -6,13 +6,7 @@
|
|||
|
||||
import React, { PureComponent } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
unit,
|
||||
units,
|
||||
px,
|
||||
colors,
|
||||
fontSizes
|
||||
} from '../../../../style/variables';
|
||||
import { units, px, colors, fontSizes } from '../../../../style/variables';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
|
@ -21,7 +15,6 @@ const Container = styled.div`
|
|||
color: ${colors.gray2};
|
||||
cursor: ${props => (props.clickable ? 'pointer' : 'initial')};
|
||||
opacity: ${props => (props.disabled ? 0.4 : 1)};
|
||||
margin-right: ${px(unit)};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import PropTypes from 'prop-types';
|
|||
import CustomPlot from '../CustomPlot';
|
||||
import { asMillis, tpmUnit, asInteger } from '../../../../utils/formatters';
|
||||
import styled from 'styled-components';
|
||||
import { units, unit, px } from '../../../../style/variables';
|
||||
import { units, unit, px, fontSizes } from '../../../../style/variables';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
import moment from 'moment';
|
||||
|
||||
|
@ -35,15 +35,26 @@ const Chart = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
const ChartHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: ${px(units.half)};
|
||||
`;
|
||||
|
||||
const ChartTitle = styled.div`
|
||||
font-weight: 600;
|
||||
font-size: ${fontSizes.large};
|
||||
`;
|
||||
|
||||
export class Charts extends Component {
|
||||
state = {
|
||||
hoverIndex: null
|
||||
hoverX: null
|
||||
};
|
||||
|
||||
onHover = hoverIndex => this.setState({ hoverIndex });
|
||||
onMouseLeave = () => this.setState({ hoverIndex: null });
|
||||
onHover = hoverX => this.setState({ hoverX });
|
||||
onMouseLeave = () => this.setState({ hoverX: null });
|
||||
onSelectionEnd = selection => {
|
||||
this.setState({ hoverIndex: null });
|
||||
this.setState({ hoverX: null });
|
||||
timefilter.setTime({
|
||||
from: moment(selection.start).toISOString(),
|
||||
to: moment(selection.end).toISOString(),
|
||||
|
@ -55,11 +66,11 @@ export class Charts extends Component {
|
|||
return this.props.charts.noHits ? '- ms' : asMillis(t);
|
||||
};
|
||||
|
||||
getResponseTimeTooltipFormatter = t => {
|
||||
getResponseTimeTooltipFormatter = (p = {}) => {
|
||||
if (this.props.charts.noHits) {
|
||||
return '- ms';
|
||||
} else {
|
||||
return t == null ? 'N/A' : asMillis(t);
|
||||
return p.y == null ? 'N/A' : asMillis(p.y);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -69,37 +80,46 @@ export class Charts extends Component {
|
|||
return charts.noHits ? `- ${unit}` : `${asInteger(t)} ${unit}`;
|
||||
};
|
||||
|
||||
getTPMTooltipFormatter = (p = {}) => {
|
||||
return this.getTPMFormatter(p.y);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { charts, urlParams } = this.props;
|
||||
const { noHits, responseTimeSeries, tpmSeries } = charts;
|
||||
const { noHits, responseTimeSeries, tpmSeries } = this.props.charts;
|
||||
const { transactionType } = this.props.urlParams;
|
||||
|
||||
return (
|
||||
<ChartsWrapper>
|
||||
<Chart>
|
||||
<ChartHeader>
|
||||
<ChartTitle>{responseTimeLabel(transactionType)}</ChartTitle>
|
||||
{this.props.ChartHeaderContent}
|
||||
</ChartHeader>
|
||||
<CustomPlot
|
||||
noHits={noHits}
|
||||
chartTitle={responseTimeLabel(urlParams.transactionType)}
|
||||
series={responseTimeSeries}
|
||||
onHover={this.onHover}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onSelectionEnd={this.onSelectionEnd}
|
||||
hoverIndex={this.state.hoverIndex}
|
||||
hoverX={this.state.hoverX}
|
||||
tickFormatY={this.getResponseTimeTickFormatter}
|
||||
formatTooltipValue={this.getResponseTimeTooltipFormatter}
|
||||
/>
|
||||
</Chart>
|
||||
|
||||
<Chart>
|
||||
<ChartHeader>
|
||||
<ChartTitle>{tpmLabel(transactionType)}</ChartTitle>
|
||||
</ChartHeader>
|
||||
<CustomPlot
|
||||
noHits={noHits}
|
||||
chartTitle={tpmLabel(urlParams.transactionType)}
|
||||
series={tpmSeries}
|
||||
onHover={this.onHover}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onSelectionEnd={this.onSelectionEnd}
|
||||
hoverIndex={this.state.hoverIndex}
|
||||
hoverX={this.state.hoverX}
|
||||
tickFormatY={this.getTPMFormatter}
|
||||
formatTooltipValue={this.getTPMFormatter}
|
||||
formatTooltipValue={this.getTPMTooltipFormatter}
|
||||
truncateLegends
|
||||
/>
|
||||
</Chart>
|
||||
|
@ -124,8 +144,14 @@ function responseTimeLabel(type) {
|
|||
}
|
||||
|
||||
Charts.propTypes = {
|
||||
urlParams: PropTypes.object.isRequired,
|
||||
charts: PropTypes.object.isRequired
|
||||
charts: PropTypes.object.isRequired,
|
||||
ChartHeaderContent: PropTypes.object,
|
||||
location: PropTypes.object.isRequired,
|
||||
urlParams: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
Charts.defaultProps = {
|
||||
ChartHeaderContent: null
|
||||
};
|
||||
|
||||
export default Charts;
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
|
@ -27,10 +27,9 @@ import LicenseChecker from './components/app/Main/LicenseChecker';
|
|||
import { history } from './utils/url';
|
||||
|
||||
chrome.setRootTemplate(template);
|
||||
|
||||
const store = configureStore();
|
||||
|
||||
initTimepicker(history, store.dispatch, () => {
|
||||
initTimepicker(history, store.dispatch).then(() => {
|
||||
ReactDOM.render(
|
||||
<Router history={history}>
|
||||
<Breadcrumbs />
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
import * as kfetchModule from 'ui/kfetch';
|
||||
import { SessionStorageMock } from './SessionStorageMock';
|
||||
import { callApi } from '../rest';
|
||||
import { callApi } from '../rest/callApi';
|
||||
|
||||
describe('rest', () => {
|
||||
describe('callApi', () => {
|
||||
let kfetchSpy;
|
||||
|
||||
beforeEach(() => {
|
|
@ -4,64 +4,10 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import 'isomorphic-fetch';
|
||||
import { camelizeKeys } from 'humps';
|
||||
import { kfetch } from 'ui/kfetch';
|
||||
import { memoize, isEmpty, first, startsWith } from 'lodash';
|
||||
import chrome from 'ui/chrome';
|
||||
import { convertKueryToEsQuery } from './kuery';
|
||||
import { getFromSavedObject } from 'ui/index_patterns/static_utils';
|
||||
|
||||
function fetchOptionsWithDebug(fetchOptions) {
|
||||
const debugEnabled =
|
||||
sessionStorage.getItem('apm_debug') === 'true' &&
|
||||
startsWith(fetchOptions.pathname, '/api/apm');
|
||||
|
||||
if (!debugEnabled) {
|
||||
return fetchOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
...fetchOptions,
|
||||
query: {
|
||||
...fetchOptions.query,
|
||||
_debug: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function callApi(fetchOptions, kibanaOptions) {
|
||||
const combinedKibanaOptions = {
|
||||
camelcase: true,
|
||||
...kibanaOptions
|
||||
};
|
||||
|
||||
const combinedFetchOptions = fetchOptionsWithDebug(fetchOptions);
|
||||
const res = await kfetch(combinedFetchOptions, combinedKibanaOptions);
|
||||
return combinedKibanaOptions.camelcase ? camelizeKeys(res) : res;
|
||||
}
|
||||
|
||||
export const getAPMIndexPattern = memoize(async () => {
|
||||
const res = await callApi({
|
||||
pathname: chrome.addBasePath(`/api/saved_objects/_find`),
|
||||
query: {
|
||||
type: 'index-pattern'
|
||||
}
|
||||
});
|
||||
|
||||
if (isEmpty(res.savedObjects)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
|
||||
const apmSavedObject = first(
|
||||
res.savedObjects.filter(
|
||||
savedObject => savedObject.attributes.title === apmIndexPattern
|
||||
)
|
||||
);
|
||||
|
||||
return getFromSavedObject(apmSavedObject);
|
||||
});
|
||||
import { convertKueryToEsQuery } from '../kuery';
|
||||
import { callApi } from './callApi';
|
||||
import { getAPMIndexPattern } from './savedObjects';
|
||||
|
||||
export async function loadLicense() {
|
||||
return callApi({
|
||||
|
@ -280,11 +226,3 @@ export async function loadErrorDistribution({
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWatch(id, watch) {
|
||||
return callApi({
|
||||
method: 'PUT',
|
||||
pathname: `/api/watcher/watch/${id}`,
|
||||
body: JSON.stringify({ type: 'json', id, watch })
|
||||
});
|
||||
}
|
39
x-pack/plugins/apm/public/services/rest/callApi.js
Normal file
39
x-pack/plugins/apm/public/services/rest/callApi.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 'isomorphic-fetch';
|
||||
import { camelizeKeys } from 'humps';
|
||||
import { kfetch } from 'ui/kfetch';
|
||||
import { startsWith } from 'lodash';
|
||||
|
||||
function fetchOptionsWithDebug(fetchOptions) {
|
||||
const debugEnabled =
|
||||
sessionStorage.getItem('apm_debug') === 'true' &&
|
||||
startsWith(fetchOptions.pathname, '/api/apm');
|
||||
|
||||
if (!debugEnabled) {
|
||||
return fetchOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
...fetchOptions,
|
||||
query: {
|
||||
...fetchOptions.query,
|
||||
_debug: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function callApi(fetchOptions, kibanaOptions) {
|
||||
const combinedKibanaOptions = {
|
||||
camelcase: true,
|
||||
...kibanaOptions
|
||||
};
|
||||
|
||||
const combinedFetchOptions = fetchOptionsWithDebug(fetchOptions);
|
||||
const res = await kfetch(combinedFetchOptions, combinedKibanaOptions);
|
||||
return combinedKibanaOptions.camelcase ? camelizeKeys(res) : res;
|
||||
}
|
31
x-pack/plugins/apm/public/services/rest/ml.js
Normal file
31
x-pack/plugins/apm/public/services/rest/ml.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
import { callApi } from './callApi';
|
||||
import { SERVICE_NAME, TRANSACTION_TYPE } from '../../../common/constants';
|
||||
|
||||
export async function startMlJob({ serviceName, transactionType }) {
|
||||
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
|
||||
return callApi({
|
||||
method: 'POST',
|
||||
pathname: `/api/ml/modules/setup/apm_transaction`,
|
||||
body: JSON.stringify({
|
||||
prefix: `${serviceName}-${transactionType}-`.toLowerCase(),
|
||||
groups: ['apm', serviceName.toLowerCase(), transactionType.toLowerCase()],
|
||||
indexPatternName: apmIndexPattern,
|
||||
startDatafeed: true,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{ term: { [SERVICE_NAME]: serviceName } },
|
||||
{ term: { [TRANSACTION_TYPE]: transactionType } }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
36
x-pack/plugins/apm/public/services/rest/savedObjects.js
Normal file
36
x-pack/plugins/apm/public/services/rest/savedObjects.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { memoize, isEmpty, first } from 'lodash';
|
||||
import chrome from 'ui/chrome';
|
||||
import { getFromSavedObject } from 'ui/index_patterns/static_utils';
|
||||
import { callApi } from './callApi';
|
||||
|
||||
export const getAPMIndexPattern = memoize(async () => {
|
||||
const res = await callApi({
|
||||
pathname: `/api/saved_objects/_find`,
|
||||
query: {
|
||||
type: 'index-pattern'
|
||||
}
|
||||
});
|
||||
|
||||
if (isEmpty(res.savedObjects)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apmIndexPattern = chrome.getInjected('apmIndexPattern');
|
||||
const apmSavedObject = first(
|
||||
res.savedObjects.filter(
|
||||
savedObject => savedObject.attributes.title === apmIndexPattern
|
||||
)
|
||||
);
|
||||
|
||||
if (!apmSavedObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getFromSavedObject(apmSavedObject);
|
||||
});
|
|
@ -4,6 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export default {
|
||||
addBasePath: (path) => path
|
||||
};
|
||||
import { callApi } from './callApi';
|
||||
|
||||
export async function createWatch(id, watch) {
|
||||
return callApi({
|
||||
method: 'PUT',
|
||||
pathname: `/api/watcher/watch/${id}`,
|
||||
body: JSON.stringify({ type: 'json', id, watch })
|
||||
});
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { 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'
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import * as rest from '../../../services/rest';
|
||||
import * as rest from '../../../services/rest/apm';
|
||||
import { getServiceList, ServiceListRequest } from '../serviceList';
|
||||
import { mountWithStore } from '../../../utils/testHelpers';
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ import { createSelector } from 'reselect';
|
|||
import { getCharts } from '../selectors/chartSelectors';
|
||||
import { getUrlParams } from '../urlParams';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadCharts } from '../../services/rest';
|
||||
import { withInitialData } from './helpers';
|
||||
import { loadCharts } from '../../services/rest/apm';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
|
||||
const ID = 'detailsCharts';
|
||||
const INITIAL_DATA = {
|
||||
|
@ -18,12 +18,14 @@ const INITIAL_DATA = {
|
|||
dates: [],
|
||||
responseTimes: {},
|
||||
tpmBuckets: [],
|
||||
weightedAverage: null
|
||||
overallAvgDuration: null
|
||||
};
|
||||
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export const getDetailsCharts = createSelector(
|
||||
getUrlParams,
|
||||
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
|
||||
state => withInitialData(state.reactReduxRequest[ID]),
|
||||
(urlParams, detailCharts) => {
|
||||
return {
|
||||
...detailCharts,
|
||||
|
|
|
@ -6,14 +6,15 @@
|
|||
|
||||
import React from 'react';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadErrorDistribution } from '../../services/rest';
|
||||
import { withInitialData } from './helpers';
|
||||
import { loadErrorDistribution } from '../../services/rest/apm';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
|
||||
const ID = 'errorDistribution';
|
||||
const INITIAL_DATA = { buckets: [], totalHits: 0 };
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getErrorDistribution(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function ErrorDistributionRequest({ urlParams, render }) {
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadErrorGroupDetails } from '../../services/rest';
|
||||
import { loadErrorGroupDetails } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'errorGroupDetails';
|
||||
const INITIAL_DATA = {};
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getErrorGroupDetails(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function ErrorGroupDetailsRequest({ urlParams, render }) {
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadErrorGroupList } from '../../services/rest';
|
||||
import { loadErrorGroupList } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'errorGroupList';
|
||||
const INITIAL_DATA = [];
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getErrorGroupList(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function ErrorGroupDetailsRequest({ urlParams, render }) {
|
||||
|
|
|
@ -4,9 +4,17 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export function withInitialData(state = {}, initialData) {
|
||||
return {
|
||||
...state,
|
||||
data: state.data || initialData
|
||||
};
|
||||
}
|
||||
import { createSelector } from 'reselect';
|
||||
import { get } from 'lodash';
|
||||
|
||||
export const createInitialDataSelector = initialData => {
|
||||
return createSelector(
|
||||
state => state,
|
||||
state => {
|
||||
return {
|
||||
...state,
|
||||
data: get(state, 'data') || initialData
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,18 +4,23 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadLicense } from '../../services/rest';
|
||||
import { loadLicense } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'license';
|
||||
const INITIAL_DATA = {
|
||||
features: { watcher: { isAvailable: false } },
|
||||
features: {
|
||||
watcher: { isAvailable: false },
|
||||
ml: { isAvailable: false }
|
||||
},
|
||||
license: { isActive: false }
|
||||
};
|
||||
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getLicense(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function LicenceRequest({ render }) {
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
import React from 'react';
|
||||
import { createSelector } from 'reselect';
|
||||
import { get, isEmpty } from 'lodash';
|
||||
import { getCharts } from '../selectors/chartSelectors';
|
||||
import { getUrlParams } from '../urlParams';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadCharts } from '../../services/rest';
|
||||
import { withInitialData } from './helpers';
|
||||
import { loadCharts } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'overviewCharts';
|
||||
const INITIAL_DATA = {
|
||||
|
@ -18,20 +18,29 @@ const INITIAL_DATA = {
|
|||
dates: [],
|
||||
responseTimes: {},
|
||||
tpmBuckets: [],
|
||||
weightedAverage: null
|
||||
overallAvgDuration: null
|
||||
};
|
||||
|
||||
export const getOverviewCharts = createSelector(
|
||||
getUrlParams,
|
||||
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
|
||||
(urlParams, overviewCharts) => {
|
||||
state => state.reactReduxRequest[ID],
|
||||
(urlParams, overviewCharts = {}) => {
|
||||
return {
|
||||
...overviewCharts,
|
||||
data: getCharts(urlParams, overviewCharts.data)
|
||||
data: getCharts(urlParams, overviewCharts.data || INITIAL_DATA)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export function hasDynamicBaseline(state) {
|
||||
return !isEmpty(
|
||||
get(
|
||||
state,
|
||||
`reactReduxRequest[${ID}].data.responseTimes.avgAnomalies.buckets`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewChartsRequest({ urlParams, render }) {
|
||||
const { serviceName, start, end, transactionType, kuery } = urlParams;
|
||||
|
||||
|
|
|
@ -7,15 +7,16 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadServiceDetails } from '../../services/rest';
|
||||
import { loadServiceDetails } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'serviceDetails';
|
||||
const INITIAL_DATA = { types: [] };
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getServiceDetails(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function getDefaultTransactionType(state) {
|
||||
|
|
|
@ -7,15 +7,16 @@
|
|||
import React from 'react';
|
||||
import orderBy from 'lodash.orderby';
|
||||
import { createSelector } from 'reselect';
|
||||
import { loadServiceList } from '../../services/rest';
|
||||
import { loadServiceList } from '../../services/rest/apm';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
|
||||
const ID = 'serviceList';
|
||||
const INITIAL_DATA = [];
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export const getServiceList = createSelector(
|
||||
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
|
||||
state => withInitialData(state.reactReduxRequest[ID]),
|
||||
state => state.sorting.service,
|
||||
(serviceList, serviceSorting) => {
|
||||
const { key: sortKey, descending } = serviceSorting;
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadSpans } from '../../services/rest';
|
||||
import { loadSpans } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'spans';
|
||||
const INITIAL_DATA = {};
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getSpans(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function SpansRequest({ urlParams, render }) {
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadTransaction } from '../../services/rest';
|
||||
import { loadTransaction } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'transactionDetails';
|
||||
const INITIAL_DATA = {};
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getTransactionDetails(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function TransactionDetailsRequest({ urlParams, render }) {
|
||||
|
|
|
@ -5,15 +5,16 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { withInitialData } from './helpers';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadTransactionDistribution } from '../../services/rest';
|
||||
import { loadTransactionDistribution } from '../../services/rest/apm';
|
||||
|
||||
const ID = 'transactionDistribution';
|
||||
const INITIAL_DATA = { buckets: [], totalHits: 0 };
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export function getTransactionDistribution(state) {
|
||||
return withInitialData(state.reactReduxRequest[ID], INITIAL_DATA);
|
||||
return withInitialData(state.reactReduxRequest[ID]);
|
||||
}
|
||||
|
||||
export function getDefaultTransactionId(state) {
|
||||
|
|
|
@ -8,14 +8,15 @@ import React from 'react';
|
|||
import orderBy from 'lodash.orderby';
|
||||
import { createSelector } from 'reselect';
|
||||
import { Request } from 'react-redux-request';
|
||||
import { loadTransactionList } from '../../services/rest';
|
||||
import { withInitialData } from './helpers';
|
||||
import { loadTransactionList } from '../../services/rest/apm';
|
||||
import { createInitialDataSelector } from './helpers';
|
||||
|
||||
const ID = 'transactionList';
|
||||
const INITIAL_DATA = [];
|
||||
const withInitialData = createInitialDataSelector(INITIAL_DATA);
|
||||
|
||||
export const getTransactionList = createSelector(
|
||||
state => withInitialData(state.reactReduxRequest[ID], INITIAL_DATA),
|
||||
state => withInitialData(state.reactReduxRequest[ID]),
|
||||
state => state.sorting.transaction,
|
||||
(transactionList = {}, transactionSorting) => {
|
||||
const { key: sortKey, descending } = transactionSorting;
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`chartSelectors getResponseTimeSeries should match snapshot 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"color": "#3185fc",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 100,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 200,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 150,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 250,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 100,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 50,
|
||||
},
|
||||
],
|
||||
"legendValue": "0 ms",
|
||||
"title": "Avg.",
|
||||
"type": "line",
|
||||
},
|
||||
Object {
|
||||
"color": "#ecae23",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 200,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 300,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 250,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 350,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 200,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 150,
|
||||
},
|
||||
],
|
||||
"title": "95th percentile",
|
||||
"titleShort": "95th",
|
||||
"type": "line",
|
||||
},
|
||||
Object {
|
||||
"color": "#f98510",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 400,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 350,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 450,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 100,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 50,
|
||||
},
|
||||
],
|
||||
"title": "99th percentile",
|
||||
"titleShort": "99th",
|
||||
"type": "line",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`chartSelectors getTpmSeries should match snapshot 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"color": "#00b3a4",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 10,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 3,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 8,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 4,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 9,
|
||||
},
|
||||
],
|
||||
"legendValue": "10.0 tpm",
|
||||
"title": "HTTP 2xx",
|
||||
"type": "line",
|
||||
},
|
||||
Object {
|
||||
"color": "#f98510",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 2,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 3,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 2,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 3,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 1,
|
||||
},
|
||||
],
|
||||
"legendValue": "2.0 tpm",
|
||||
"title": "HTTP 4xx",
|
||||
"type": "line",
|
||||
},
|
||||
Object {
|
||||
"color": "#db1374",
|
||||
"data": Array [
|
||||
Object {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
Object {
|
||||
"x": 1000,
|
||||
"y": 1,
|
||||
},
|
||||
Object {
|
||||
"x": 2000,
|
||||
"y": 2,
|
||||
},
|
||||
Object {
|
||||
"x": 3000,
|
||||
"y": 1,
|
||||
},
|
||||
Object {
|
||||
"x": 4000,
|
||||
"y": 0,
|
||||
},
|
||||
Object {
|
||||
"x": 5000,
|
||||
"y": 2,
|
||||
},
|
||||
],
|
||||
"legendValue": "1.0 tpm",
|
||||
"title": "HTTP 5xx",
|
||||
"type": "line",
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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 {
|
||||
getAnomalyScoreValues,
|
||||
getResponseTimeSeries,
|
||||
getTpmSeries,
|
||||
getAnomalyBoundaryValues
|
||||
} from '../chartSelectors';
|
||||
|
||||
import anomalyData from './mockData/anomalyData.json';
|
||||
|
||||
describe('chartSelectors', () => {
|
||||
describe('getAnomalyScoreValues', () => {
|
||||
it('should return anomaly score series', () => {
|
||||
const dates = [0, 1000, 2000, 3000, 4000, 5000, 6000];
|
||||
const buckets = [
|
||||
{
|
||||
anomalyScore: null
|
||||
},
|
||||
{
|
||||
anomalyScore: 80
|
||||
},
|
||||
{
|
||||
anomalyScore: 0
|
||||
},
|
||||
{
|
||||
anomalyScore: 0
|
||||
},
|
||||
{
|
||||
anomalyScore: 70
|
||||
},
|
||||
{
|
||||
anomalyScore: 80
|
||||
},
|
||||
{
|
||||
anomalyScore: 0
|
||||
}
|
||||
];
|
||||
|
||||
expect(getAnomalyScoreValues(dates, buckets, 1000)).toEqual([
|
||||
{ x: 1000, y: 1 },
|
||||
{ x: 2000, y: 1 },
|
||||
{ x: 3000 },
|
||||
{ x: 5000, y: 1 },
|
||||
{ x: 6000, y: 1 },
|
||||
{ x: 7000 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseTimeSeries', () => {
|
||||
const chartsData = {
|
||||
dates: [0, 1000, 2000, 3000, 4000, 5000],
|
||||
responseTimes: {
|
||||
avg: [100, 200, 150, 250, 100, 50],
|
||||
p95: [200, 300, 250, 350, 200, 150],
|
||||
p99: [300, 400, 350, 450, 100, 50],
|
||||
avgAnomalies: {}
|
||||
},
|
||||
overallAvgDuration: 200
|
||||
};
|
||||
|
||||
it('should match snapshot', () => {
|
||||
expect(getResponseTimeSeries(chartsData)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should return 3 series', () => {
|
||||
expect(getResponseTimeSeries(chartsData).length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTpmSeries', () => {
|
||||
const chartsData = {
|
||||
dates: [0, 1000, 2000, 3000, 4000, 5000],
|
||||
tpmBuckets: [
|
||||
{
|
||||
key: 'HTTP 2xx',
|
||||
avg: 10,
|
||||
values: [5, 10, 3, 8, 4, 9]
|
||||
},
|
||||
{
|
||||
key: 'HTTP 4xx',
|
||||
avg: 2,
|
||||
values: [1, 2, 3, 2, 3, 1]
|
||||
},
|
||||
{
|
||||
key: 'HTTP 5xx',
|
||||
avg: 1,
|
||||
values: [0, 1, 2, 1, 0, 2]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const transactionType = 'MyTransactionType';
|
||||
|
||||
it('should match snapshot', () => {
|
||||
expect(getTpmSeries(chartsData, transactionType)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnomalyBoundaryValues', () => {
|
||||
const { dates, buckets } = anomalyData;
|
||||
const bucketSpan = 240000;
|
||||
|
||||
it('should return correct buckets', () => {
|
||||
expect(getAnomalyBoundaryValues(dates, buckets, bucketSpan)).toEqual([
|
||||
{ x: 1530614880000, y: 54799, y0: 15669 },
|
||||
{ x: 1530615060000, y: 49874, y0: 17808 },
|
||||
{ x: 1530615300000, y: 49421, y0: 18012 },
|
||||
{ x: 1530615540000, y: 49654, y0: 17889 },
|
||||
{ x: 1530615780000, y: 50026, y0: 17713 },
|
||||
{ x: 1530616020000, y: 49371, y0: 18044 },
|
||||
{ x: 1530616260000, y: 50110, y0: 17713 },
|
||||
{ x: 1530616500000, y: 50419, y0: 17582 },
|
||||
{ x: 1530616620000, y: 50419, y0: 17582 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extend the last bucket with a size of bucketSpan', () => {
|
||||
const [lastBucket, secondLastBuckets] = getAnomalyBoundaryValues(
|
||||
dates,
|
||||
buckets,
|
||||
bucketSpan
|
||||
).reverse();
|
||||
|
||||
expect(secondLastBuckets.y).toBe(lastBucket.y);
|
||||
expect(secondLastBuckets.y0).toBe(lastBucket.y0);
|
||||
expect(lastBucket.x - secondLastBuckets.x).toBeLessThanOrEqual(
|
||||
bucketSpan
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,186 @@
|
|||
{
|
||||
"dates": [
|
||||
1530614880000,
|
||||
1530614940000,
|
||||
1530615000000,
|
||||
1530615060000,
|
||||
1530615120000,
|
||||
1530615180000,
|
||||
1530615240000,
|
||||
1530615300000,
|
||||
1530615360000,
|
||||
1530615420000,
|
||||
1530615480000,
|
||||
1530615540000,
|
||||
1530615600000,
|
||||
1530615660000,
|
||||
1530615720000,
|
||||
1530615780000,
|
||||
1530615840000,
|
||||
1530615900000,
|
||||
1530615960000,
|
||||
1530616020000,
|
||||
1530616080000,
|
||||
1530616140000,
|
||||
1530616200000,
|
||||
1530616260000,
|
||||
1530616320000,
|
||||
1530616380000,
|
||||
1530616440000,
|
||||
1530616500000,
|
||||
1530616560000,
|
||||
1530616620000
|
||||
],
|
||||
"buckets": [
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": 15669,
|
||||
"upper": 54799
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 17808,
|
||||
"upper": 49874
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 18012,
|
||||
"upper": 49421
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 17889,
|
||||
"upper": 49654
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 17713,
|
||||
"upper": 50026
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 18044,
|
||||
"upper": 49371
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 17713,
|
||||
"upper": 50110
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": 0,
|
||||
"lower": 17582,
|
||||
"upper": 50419
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
},
|
||||
{
|
||||
"anomalyScore": null,
|
||||
"lower": null,
|
||||
"upper": null
|
||||
}
|
||||
]
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import d3 from 'd3';
|
||||
import { zipObject, difference, memoize } from 'lodash';
|
||||
import { last, zipObject, difference, memoize, get, isEmpty } from 'lodash';
|
||||
import { colors } from '../../style/variables';
|
||||
import {
|
||||
asMillisWithDefault,
|
||||
|
@ -51,49 +51,78 @@ export function getCharts(urlParams, charts) {
|
|||
}
|
||||
|
||||
export function getResponseTimeSeries(chartsData) {
|
||||
const { dates, weightedAverage } = chartsData;
|
||||
const { avg, p95, p99 } = chartsData.responseTimes;
|
||||
const { dates, overallAvgDuration } = chartsData;
|
||||
const { avg, p95, p99, avgAnomalies } = chartsData.responseTimes;
|
||||
|
||||
return [
|
||||
const series = [
|
||||
{
|
||||
title: 'Avg.',
|
||||
data: getChartValues(dates, avg),
|
||||
legendValue: `${asMillisWithDefault(weightedAverage)}`,
|
||||
type: 'area',
|
||||
color: colors.apmBlue,
|
||||
areaColor: 'rgba(49, 133, 252, 0.1)' // apmBlue
|
||||
legendValue: `${asMillisWithDefault(overallAvgDuration)}`,
|
||||
type: 'line',
|
||||
color: colors.apmBlue
|
||||
},
|
||||
{
|
||||
title: '95th percentile',
|
||||
titleShort: '95th',
|
||||
data: getChartValues(dates, p95),
|
||||
type: 'area',
|
||||
color: colors.apmYellow,
|
||||
areaColor: 'rgba(236, 174, 35, 0.1)' // apmYellow
|
||||
type: 'line',
|
||||
color: colors.apmYellow
|
||||
},
|
||||
{
|
||||
title: '99th percentile',
|
||||
titleShort: '99th',
|
||||
data: getChartValues(dates, p99),
|
||||
type: 'area',
|
||||
color: colors.apmOrange,
|
||||
areaColor: 'rgba(249, 133, 16, 0.1)' // apmOrange
|
||||
type: 'line',
|
||||
color: colors.apmOrange
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getTpmLegendTitle(bucketKey) {
|
||||
// hide legend text for transactions without "result"
|
||||
if (bucketKey === 'transaction_result_missing') {
|
||||
return '';
|
||||
if (!isEmpty(avgAnomalies.buckets)) {
|
||||
// insert after Avg. serie
|
||||
series.splice(1, 0, {
|
||||
title: 'Anomaly Boundaries',
|
||||
hideLegend: true,
|
||||
hideTooltipValue: true,
|
||||
data: getAnomalyBoundaryValues(
|
||||
dates,
|
||||
avgAnomalies.buckets,
|
||||
avgAnomalies.bucketSpanAsMillis
|
||||
),
|
||||
type: 'area',
|
||||
color: 'none',
|
||||
areaColor: 'rgba(49, 133, 252, 0.1)' // apmBlue
|
||||
});
|
||||
|
||||
series.splice(1, 0, {
|
||||
title: 'Anomaly score',
|
||||
hideLegend: true,
|
||||
hideTooltipValue: true,
|
||||
data: getAnomalyScoreValues(
|
||||
dates,
|
||||
avgAnomalies.buckets,
|
||||
avgAnomalies.bucketSpanAsMillis
|
||||
),
|
||||
type: 'areaMaxHeight',
|
||||
color: 'none',
|
||||
areaColor: 'rgba(146, 0, 0, 0.1)' // apmRed
|
||||
});
|
||||
}
|
||||
|
||||
return bucketKey;
|
||||
return series;
|
||||
}
|
||||
|
||||
function getTpmSeries(chartsData, transactionType) {
|
||||
export function getTpmSeries(chartsData, transactionType) {
|
||||
const { dates, tpmBuckets } = chartsData;
|
||||
const getColor = getColorByKey(tpmBuckets.map(({ key }) => key));
|
||||
const getTpmLegendTitle = bucketKey => {
|
||||
// hide legend text for transactions without "result"
|
||||
if (bucketKey === 'transaction_result_missing') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return bucketKey;
|
||||
};
|
||||
|
||||
return tpmBuckets.map(bucket => {
|
||||
return {
|
||||
|
@ -127,9 +156,73 @@ function getColorByKey(keys) {
|
|||
return key => assignedColors[key] || unassignedColors[key];
|
||||
}
|
||||
|
||||
function getChartValues(dates = [], yValues = []) {
|
||||
function getChartValues(dates = [], buckets = []) {
|
||||
return dates.map((x, i) => ({
|
||||
x,
|
||||
y: yValues[i]
|
||||
y: buckets[i]
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAnomalyScoreValues(
|
||||
dates = [],
|
||||
buckets = [],
|
||||
bucketSpanAsMillis
|
||||
) {
|
||||
const ANOMALY_THRESHOLD = 75;
|
||||
const getX = (currentX, i) => currentX + bucketSpanAsMillis * i;
|
||||
|
||||
return dates
|
||||
.map((x, i) => {
|
||||
const { anomalyScore } = buckets[i] || {};
|
||||
return {
|
||||
x,
|
||||
anomalyScore
|
||||
};
|
||||
})
|
||||
.filter(p => p.anomalyScore > ANOMALY_THRESHOLD)
|
||||
.reduce((acc, p, i, points) => {
|
||||
acc.push({ x: p.x, y: 1 });
|
||||
const { x: nextX } = points[i + 1] || {};
|
||||
const endX = getX(p.x, 1);
|
||||
if (nextX == null || nextX > endX) {
|
||||
acc.push(
|
||||
{
|
||||
x: endX,
|
||||
y: 1
|
||||
},
|
||||
{
|
||||
x: getX(p.x, 2)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function getAnomalyBoundaryValues(
|
||||
dates = [],
|
||||
buckets = [],
|
||||
bucketSpanAsMillis
|
||||
) {
|
||||
const lastX = last(dates);
|
||||
return dates
|
||||
.map((x, i) => ({
|
||||
x,
|
||||
y0: get(buckets[i], 'lower'),
|
||||
y: get(buckets[i], 'upper')
|
||||
}))
|
||||
.filter(point => point.y != null)
|
||||
.reduce((acc, p, i, points) => {
|
||||
const isLast = last(points) === p;
|
||||
acc.push(p);
|
||||
|
||||
if (isLast) {
|
||||
acc.push({
|
||||
...p,
|
||||
x: Math.min(p.x + bucketSpanAsMillis, lastX) // avoid going beyond the last date
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
|
|
@ -13,10 +13,85 @@ import {
|
|||
toQuery,
|
||||
fromQuery,
|
||||
KibanaLinkComponent,
|
||||
RelativeLinkComponent
|
||||
RelativeLinkComponent,
|
||||
encodeKibanaSearchParams,
|
||||
decodeKibanaSearchParams
|
||||
} from '../url';
|
||||
import { toJson } from '../testHelpers';
|
||||
|
||||
describe('encodeKibanaSearchParams and decodeKibanaSearchParams should return the original string', () => {
|
||||
it('should convert string to object', () => {
|
||||
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`;
|
||||
const nextSearch = encodeKibanaSearchParams(
|
||||
decodeKibanaSearchParams(search)
|
||||
);
|
||||
expect(search).toBe(`?${nextSearch}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeKibanaSearchParams', () => {
|
||||
it('when both _a and _g are defined', () => {
|
||||
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`;
|
||||
const query = decodeKibanaSearchParams(search);
|
||||
expect(query).toEqual({
|
||||
_a: {
|
||||
filters: [],
|
||||
mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } },
|
||||
mlSelectSeverity: { threshold: { display: 'warning', val: 0 } },
|
||||
mlTimeSeriesExplorer: {},
|
||||
query: { query_string: { analyze_wildcard: true, query: '*' } }
|
||||
},
|
||||
_g: {
|
||||
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] },
|
||||
refreshInterval: { display: 'Off', pause: false, value: 0 },
|
||||
time: {
|
||||
from: '2018-06-06T08:20:45.437Z',
|
||||
mode: 'absolute',
|
||||
to: '2018-06-14T21:56:58.505Z'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('when only _g is defined', () => {
|
||||
const search = `?_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)))`;
|
||||
const query = decodeKibanaSearchParams(search);
|
||||
expect(query).toEqual({
|
||||
_a: null,
|
||||
_g: {
|
||||
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeKibanaSearchParams', () => {
|
||||
it('should convert object to string', () => {
|
||||
const query = {
|
||||
_a: {
|
||||
filters: [],
|
||||
mlSelectInterval: { interval: { display: 'Auto', val: 'auto' } },
|
||||
mlSelectSeverity: { threshold: { display: 'warning', val: 0 } },
|
||||
mlTimeSeriesExplorer: {},
|
||||
query: { query_string: { analyze_wildcard: true, query: '*' } }
|
||||
},
|
||||
_g: {
|
||||
ml: { jobIds: ['opbeans-node-request-high_mean_response_time'] },
|
||||
refreshInterval: { display: 'Off', pause: false, value: 0 },
|
||||
time: {
|
||||
from: '2018-06-06T08:20:45.437Z',
|
||||
mode: 'absolute',
|
||||
to: '2018-06-14T21:56:58.505Z'
|
||||
}
|
||||
}
|
||||
};
|
||||
const search = encodeKibanaSearchParams(query);
|
||||
expect(search).toBe(
|
||||
`_g=(ml:(jobIds:!(opbeans-node-request-high_mean_response_time)),refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2018-06-06T08:20:45.437Z',mode:absolute,to:'2018-06-14T21:56:58.505Z'))&_a=(filters:!(),mlSelectInterval:(interval:(display:Auto,val:auto)),mlSelectSeverity:(threshold:(display:warning,val:0)),mlTimeSeriesExplorer:(),query:(query_string:(analyze_wildcard:!t,query:'*')))`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toQuery', () => {
|
||||
it('should parse string to object', () => {
|
||||
expect(toQuery('?foo=bar&name=john%20doe')).toEqual({
|
||||
|
|
|
@ -23,47 +23,49 @@ const waitForAngularReady = new Promise(resolve => {
|
|||
}, 10);
|
||||
});
|
||||
|
||||
export function initTimepicker(history, dispatch, callback) {
|
||||
// default the timepicker to the last 24 hours
|
||||
chrome.getUiSettingsClient().overrideLocalDefault(
|
||||
'timepicker:timeDefaults',
|
||||
JSON.stringify({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
mode: 'quick'
|
||||
})
|
||||
);
|
||||
export function initTimepicker(history, dispatch) {
|
||||
return new Promise(resolve => {
|
||||
// default the timepicker to the last 24 hours
|
||||
chrome.getUiSettingsClient().overrideLocalDefault(
|
||||
'timepicker:timeDefaults',
|
||||
JSON.stringify({
|
||||
from: 'now-24h',
|
||||
to: 'now',
|
||||
mode: 'quick'
|
||||
})
|
||||
);
|
||||
|
||||
uiModules
|
||||
.get('app/apm', [])
|
||||
.controller('TimePickerController', ($scope, globalState) => {
|
||||
// Add APM feedback menu
|
||||
// TODO: move this somewhere else
|
||||
$scope.topNavMenu = [];
|
||||
$scope.topNavMenu.push({
|
||||
key: 'APM feedback',
|
||||
description: 'APM feedback',
|
||||
tooltip: 'Provide feedback on APM',
|
||||
template: require('../../templates/feedback_menu.html')
|
||||
});
|
||||
uiModules
|
||||
.get('app/apm', [])
|
||||
.controller('TimePickerController', ($scope, globalState) => {
|
||||
// Add APM feedback menu
|
||||
// TODO: move this somewhere else
|
||||
$scope.topNavMenu = [];
|
||||
$scope.topNavMenu.push({
|
||||
key: 'APM feedback',
|
||||
description: 'APM feedback',
|
||||
tooltip: 'Provide feedback on APM',
|
||||
template: require('../../templates/feedback_menu.html')
|
||||
});
|
||||
|
||||
history.listen(() => {
|
||||
updateRefreshRate(dispatch);
|
||||
globalState.fetch();
|
||||
});
|
||||
timefilter.enableTimeRangeSelector();
|
||||
timefilter.enableAutoRefreshSelector();
|
||||
|
||||
history.listen(() => {
|
||||
updateRefreshRate(dispatch);
|
||||
globalState.fetch();
|
||||
|
||||
$scope.$listen(timefilter, 'timeUpdate', () =>
|
||||
dispatch(updateTimePickerAction())
|
||||
);
|
||||
|
||||
registerTimefilterWithGlobalState(globalState);
|
||||
|
||||
Promise.all([waitForAngularReady]).then(resolve);
|
||||
});
|
||||
timefilter.enableTimeRangeSelector();
|
||||
timefilter.enableAutoRefreshSelector();
|
||||
|
||||
updateRefreshRate(dispatch);
|
||||
|
||||
$scope.$listen(timefilter, 'timeUpdate', () =>
|
||||
dispatch(updateTimePickerAction())
|
||||
);
|
||||
|
||||
registerTimefilterWithGlobalState(globalState);
|
||||
|
||||
Promise.all([waitForAngularReady]).then(callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimePickerAction() {
|
||||
|
|
|
@ -16,6 +16,21 @@ import { EuiLink } from '@elastic/eui';
|
|||
import createHistory from 'history/createHashHistory';
|
||||
import chrome from 'ui/chrome';
|
||||
|
||||
export function getMlJobUrl(serviceName, transactionType, location) {
|
||||
const { _g, _a } = decodeKibanaSearchParams(location.search);
|
||||
const nextSearch = encodeKibanaSearchParams({
|
||||
_g: {
|
||||
..._g,
|
||||
ml: {
|
||||
jobIds: [`${serviceName}-${transactionType}-high_mean_response_time`]
|
||||
}
|
||||
},
|
||||
_a
|
||||
});
|
||||
|
||||
return `/app/ml#/timeseriesexplorer/?${nextSearch}`;
|
||||
}
|
||||
|
||||
export function toQuery(search) {
|
||||
return qs.parse(search.slice(1));
|
||||
}
|
||||
|
@ -40,6 +55,21 @@ function stringifyWithoutEncoding(query) {
|
|||
});
|
||||
}
|
||||
|
||||
export function decodeKibanaSearchParams(search) {
|
||||
const query = toQuery(search);
|
||||
return {
|
||||
_g: query._g ? rison.decode(query._g) : null,
|
||||
_a: query._a ? rison.decode(query._a) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeKibanaSearchParams(query) {
|
||||
return stringifyWithoutEncoding({
|
||||
_g: rison.encode(query._g),
|
||||
_a: rison.encode(query._a)
|
||||
});
|
||||
}
|
||||
|
||||
export function RelativeLinkComponent({
|
||||
location,
|
||||
path,
|
||||
|
@ -77,7 +107,7 @@ export function KibanaLinkComponent({
|
|||
location,
|
||||
pathname,
|
||||
hash,
|
||||
query,
|
||||
query = {},
|
||||
...props
|
||||
}) {
|
||||
const currentQuery = toQuery(location.search);
|
||||
|
@ -85,6 +115,7 @@ export function KibanaLinkComponent({
|
|||
_g: query._g ? rison.encode(query._g) : currentQuery._g,
|
||||
_a: query._a ? rison.encode(query._a) : ''
|
||||
};
|
||||
|
||||
const search = stringifyWithoutEncoding(nextQuery);
|
||||
const href = url.format({
|
||||
pathname: chrome.addBasePath(pathname),
|
||||
|
|
|
@ -26,6 +26,7 @@ export function setupRequest(req, reply) {
|
|||
req.query
|
||||
)}`
|
||||
);
|
||||
console.log(`GET ${params.index}/_search`);
|
||||
console.log(JSON.stringify(params.body, null, 4));
|
||||
}
|
||||
return cluster.callWithRequest(req, type, params);
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -6,20 +6,25 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
import { getTimeseriesData } from '../get_timeseries_data';
|
||||
import elasticSearchResponse from './es_response.json';
|
||||
import timeseriesResponse from './timeseries_response.json';
|
||||
import responseTimeAnomalyResponse from './response_time_anomaly_response.json';
|
||||
|
||||
describe('get_timeseries_data', () => {
|
||||
let res;
|
||||
let clientSpy;
|
||||
beforeEach(async () => {
|
||||
clientSpy = jest.fn(() => Promise.resolve(elasticSearchResponse));
|
||||
clientSpy = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(timeseriesResponse)
|
||||
.mockResolvedValueOnce(responseTimeAnomalyResponse);
|
||||
|
||||
res = await getTimeseriesData({
|
||||
serviceName: 'myServiceName',
|
||||
transactionType: 'myTransactionType',
|
||||
transactionName: 'myTransactionName',
|
||||
transactionName: null,
|
||||
setup: {
|
||||
start: 1523307608624,
|
||||
end: 1523350808624,
|
||||
start: 1528113600000,
|
||||
end: 1528977600000,
|
||||
client: clientSpy,
|
||||
config: {
|
||||
get: () => 'myIndex'
|
||||
|
@ -33,18 +38,19 @@ describe('get_timeseries_data', () => {
|
|||
});
|
||||
|
||||
it('should not contain first and last bucket', () => {
|
||||
const esResponseDates = elasticSearchResponse.aggregations.transaction_results.buckets[0].timeseries.buckets.map(
|
||||
const mockDates = timeseriesResponse.aggregations.transaction_results.buckets[0].timeseries.buckets.map(
|
||||
bucket => bucket.key
|
||||
);
|
||||
|
||||
expect(res.dates).not.toContain(_.first(esResponseDates));
|
||||
expect(res.dates).not.toContain(_.last(esResponseDates));
|
||||
expect(res.dates).not.toContain(_.first(mockDates));
|
||||
expect(res.dates).not.toContain(_.last(mockDates));
|
||||
expect(res.tpm_buckets[0].values).toHaveLength(res.dates.length);
|
||||
});
|
||||
|
||||
it('should have correct order', () => {
|
||||
expect(res.tpm_buckets.map(bucket => bucket.key)).toEqual([
|
||||
'HTTP 2xx',
|
||||
'HTTP 3xx',
|
||||
'HTTP 4xx',
|
||||
'HTTP 5xx',
|
||||
'A Custom Bucket (that should be last)'
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,118 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`get_buckets_with_initial_anomaly_bounds should return correct buckets 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": null,
|
||||
},
|
||||
"doc_count": 0,
|
||||
"key": 1530523000000,
|
||||
"key_as_string": "2018-07-02T09:16:40.000Z",
|
||||
"lower": Object {
|
||||
"value": 17688.182675688193,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": 50381.01051622894,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": null,
|
||||
},
|
||||
"doc_count": 4,
|
||||
"key": 1530523500000,
|
||||
"key_as_string": "2018-07-02T09:25:00.000Z",
|
||||
"lower": Object {
|
||||
"value": null,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": null,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": null,
|
||||
},
|
||||
"doc_count": 0,
|
||||
"key": 1530524000000,
|
||||
"key_as_string": "2018-07-02T09:33:20.000Z",
|
||||
"lower": Object {
|
||||
"value": null,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": null,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": 0,
|
||||
},
|
||||
"doc_count": 2,
|
||||
"key": 1530524500000,
|
||||
"key_as_string": "2018-07-02T09:41:40.000Z",
|
||||
"lower": Object {
|
||||
"value": 16034.081569306454,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": 54158.77731018045,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": null,
|
||||
},
|
||||
"doc_count": 0,
|
||||
"key": 1530525000000,
|
||||
"key_as_string": "2018-07-02T09:50:00.000Z",
|
||||
"lower": Object {
|
||||
"value": null,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": null,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": 0,
|
||||
},
|
||||
"doc_count": 2,
|
||||
"key": 1530525500000,
|
||||
"key_as_string": "2018-07-02T09:58:20.000Z",
|
||||
"lower": Object {
|
||||
"value": 16034.081569306454,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": 54158.77731018045,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": null,
|
||||
},
|
||||
"doc_count": 0,
|
||||
"key": 1530526000000,
|
||||
"key_as_string": "2018-07-02T10:06:40.000Z",
|
||||
"lower": Object {
|
||||
"value": null,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": null,
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"anomaly_score": Object {
|
||||
"value": 0,
|
||||
},
|
||||
"doc_count": 2,
|
||||
"key": 1530526500000,
|
||||
"key_as_string": "2018-07-02T10:15:00.000Z",
|
||||
"lower": Object {
|
||||
"value": 16034.081569306454,
|
||||
},
|
||||
"upper": Object {
|
||||
"value": 54158.77731018045,
|
||||
},
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { getAvgResponseTimeAnomalies } from '../get_avg_response_time_anomalies';
|
||||
import mainBucketsResponse from './mockData/mainBucketsResponse';
|
||||
import firstBucketsResponse from './mockData/firstBucketsResponse';
|
||||
|
||||
describe('get_avg_response_time_anomalies', () => {
|
||||
it('', async () => {
|
||||
const clientSpy = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mainBucketsResponse)
|
||||
.mockResolvedValueOnce(firstBucketsResponse);
|
||||
|
||||
const avgAnomalies = await getAvgResponseTimeAnomalies({
|
||||
serviceName: 'myServiceName',
|
||||
transactionType: 'myTransactionType',
|
||||
setup: {
|
||||
start: 1528113600000,
|
||||
end: 1528977600000,
|
||||
client: clientSpy,
|
||||
config: {
|
||||
get: () => 'myIndex'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(avgAnomalies).toEqual({
|
||||
bucketSpanAsMillis: 10800000,
|
||||
buckets: [
|
||||
{
|
||||
anomaly_score: null,
|
||||
lower: 17688.182675688193,
|
||||
upper: 50381.01051622894
|
||||
},
|
||||
{ anomaly_score: null, lower: null, upper: null },
|
||||
{
|
||||
anomaly_score: 0,
|
||||
lower: 16034.081569306454,
|
||||
upper: 54158.77731018045
|
||||
},
|
||||
{ anomaly_score: null, lower: null, upper: null },
|
||||
{
|
||||
anomaly_score: 0,
|
||||
lower: 16034.081569306454,
|
||||
upper: 54158.77731018045
|
||||
},
|
||||
{ anomaly_score: null, lower: null, upper: null }
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 { getBucketWithInitialAnomalyBounds } from '../get_buckets_with_initial_anomaly_bounds';
|
||||
import mainBucketsResponse from './mockData/mainBucketsResponse';
|
||||
import firstBucketsResponse from './mockData/firstBucketsResponse';
|
||||
|
||||
describe('get_buckets_with_initial_anomaly_bounds', () => {
|
||||
const mainBuckets =
|
||||
mainBucketsResponse.aggregations.ml_avg_response_times.buckets;
|
||||
let buckets;
|
||||
|
||||
beforeEach(async () => {
|
||||
buckets = await getBucketWithInitialAnomalyBounds({
|
||||
serviceName: 'myServiceName',
|
||||
transactionType: 'myTransactionType',
|
||||
intervalString: '60s',
|
||||
start: 1530523322742,
|
||||
client: () => firstBucketsResponse,
|
||||
mainBuckets,
|
||||
anomalyBucketSpan: 900
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct buckets', () => {
|
||||
expect(buckets).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not change the number of buckets', () => {
|
||||
expect(mainBuckets.length).toEqual(buckets.length);
|
||||
});
|
||||
|
||||
it('should replace the first bucket but leave all other buckets the same', () => {
|
||||
buckets.forEach((bucket, i) => {
|
||||
if (i === 0) {
|
||||
expect(mainBuckets[0]).not.toEqual(bucket);
|
||||
} else {
|
||||
expect(mainBuckets[i]).toBe(bucket);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
"took": 22,
|
||||
"timed_out": false,
|
||||
"_shards": {
|
||||
"total": 5,
|
||||
"successful": 5,
|
||||
"skipped": 0,
|
||||
"failed": 0
|
||||
},
|
||||
"hits": {
|
||||
"total": 2,
|
||||
"max_score": 0,
|
||||
"hits": []
|
||||
},
|
||||
"aggregations": {
|
||||
"ml_avg_response_times": {
|
||||
"buckets": [
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:00:00.000Z",
|
||||
"key": 1530522000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:08:20.000Z",
|
||||
"key": 1530522500000,
|
||||
"doc_count": 2,
|
||||
"anomaly_score": {
|
||||
"value": 0
|
||||
},
|
||||
"upper": {
|
||||
"value": 50381.01051622894
|
||||
},
|
||||
"lower": {
|
||||
"value": 17688.182675688193
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:16:40.000Z",
|
||||
"key": 1530523000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"top_hits": {
|
||||
"hits": {
|
||||
"total": 2,
|
||||
"max_score": null,
|
||||
"hits": [
|
||||
{
|
||||
"_index": ".ml-anomalies-shared",
|
||||
"_type": "doc",
|
||||
"_id": "opbeans-node-request-high_mean_response_time_model_plot_1530522900000_900_0_29791_0",
|
||||
"_score": null,
|
||||
"_source": {
|
||||
"bucket_span": 900
|
||||
},
|
||||
"sort": [
|
||||
900
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"took": 3,
|
||||
"timed_out": false,
|
||||
"_shards": {
|
||||
"total": 5,
|
||||
"successful": 5,
|
||||
"skipped": 0,
|
||||
"failed": 0
|
||||
},
|
||||
"hits": {
|
||||
"total": 10,
|
||||
"max_score": 0,
|
||||
"hits": []
|
||||
},
|
||||
"aggregations": {
|
||||
"ml_avg_response_times": {
|
||||
"buckets": [
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:16:40.000Z",
|
||||
"key": 1530523000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:25:00.000Z",
|
||||
"key": 1530523500000,
|
||||
"doc_count": 4,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:33:20.000Z",
|
||||
"key": 1530524000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:41:40.000Z",
|
||||
"key": 1530524500000,
|
||||
"doc_count": 2,
|
||||
"anomaly_score": {
|
||||
"value": 0
|
||||
},
|
||||
"upper": {
|
||||
"value": 54158.77731018045
|
||||
},
|
||||
"lower": {
|
||||
"value": 16034.081569306454
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:50:00.000Z",
|
||||
"key": 1530525000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T09:58:20.000Z",
|
||||
"key": 1530525500000,
|
||||
"doc_count": 2,
|
||||
"anomaly_score": {
|
||||
"value": 0
|
||||
},
|
||||
"upper": {
|
||||
"value": 54158.77731018045
|
||||
},
|
||||
"lower": {
|
||||
"value": 16034.081569306454
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T10:06:40.000Z",
|
||||
"key": 1530526000000,
|
||||
"doc_count": 0,
|
||||
"anomaly_score": {
|
||||
"value": null
|
||||
},
|
||||
"upper": {
|
||||
"value": null
|
||||
},
|
||||
"lower": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"key_as_string": "2018-07-02T10:15:00.000Z",
|
||||
"key": 1530526500000,
|
||||
"doc_count": 2,
|
||||
"anomaly_score": {
|
||||
"value": 0
|
||||
},
|
||||
"upper": {
|
||||
"value": 54158.77731018045
|
||||
},
|
||||
"lower": {
|
||||
"value": 16034.081569306454
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"top_hits": {
|
||||
"hits": {
|
||||
"total": 2,
|
||||
"max_score": null,
|
||||
"hits": [
|
||||
{
|
||||
"_index": ".ml-anomalies-shared",
|
||||
"_type": "doc",
|
||||
"_id":
|
||||
"opbeans-node-request-high_mean_response_time_model_plot_1530522900000_900_0_29791_0",
|
||||
"_score": null,
|
||||
"_source": {
|
||||
"bucket_span": 900
|
||||
},
|
||||
"sort": [900]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 async function getAnomalyAggs({
|
||||
serviceName,
|
||||
transactionType,
|
||||
intervalString,
|
||||
client,
|
||||
start,
|
||||
end
|
||||
}) {
|
||||
const params = {
|
||||
index: `.ml-anomalies-${serviceName}-${transactionType}-high_mean_response_time`.toLowerCase(),
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
range: {
|
||||
timestamp: {
|
||||
gte: start,
|
||||
lte: end,
|
||||
format: 'epoch_millis'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
top_hits: {
|
||||
top_hits: {
|
||||
sort: ['bucket_span'],
|
||||
_source: { includes: ['bucket_span'] },
|
||||
size: 1
|
||||
}
|
||||
},
|
||||
ml_avg_response_times: {
|
||||
date_histogram: {
|
||||
field: 'timestamp',
|
||||
interval: intervalString,
|
||||
min_doc_count: 0,
|
||||
extended_bounds: {
|
||||
min: start,
|
||||
max: end
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
anomaly_score: { max: { field: 'anomaly_score' } },
|
||||
lower: { min: { field: 'model_lower' } },
|
||||
upper: { max: { field: 'model_upper' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await client('search', params);
|
||||
return resp.aggregations;
|
||||
} catch (e) {
|
||||
if (e.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 { getBucketSize } from '../../../helpers/get_bucket_size';
|
||||
import { getAnomalyAggs } from './get_anomaly_aggs';
|
||||
import { getBucketWithInitialAnomalyBounds } from './get_buckets_with_initial_anomaly_bounds';
|
||||
|
||||
export async function getAvgResponseTimeAnomalies({
|
||||
serviceName,
|
||||
transactionType,
|
||||
transactionName,
|
||||
setup
|
||||
}) {
|
||||
const { start, end, client } = setup;
|
||||
const { intervalString, bucketSize } = getBucketSize(start, end, 'auto');
|
||||
|
||||
// don't fetch anomalies for transaction details page
|
||||
if (transactionName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const aggs = await getAnomalyAggs({
|
||||
serviceName,
|
||||
transactionType,
|
||||
intervalString,
|
||||
client,
|
||||
start,
|
||||
end
|
||||
});
|
||||
|
||||
if (!aggs) {
|
||||
return {
|
||||
message: 'ml index does not exist'
|
||||
};
|
||||
}
|
||||
|
||||
const anomalyBucketSpan = get(
|
||||
aggs,
|
||||
'top_hits.hits.hits[0]._source.bucket_span'
|
||||
);
|
||||
|
||||
const mainBuckets = get(aggs, 'ml_avg_response_times.buckets', []).slice(
|
||||
1,
|
||||
-1
|
||||
);
|
||||
|
||||
const bucketsWithInitialAnomalyBounds = await getBucketWithInitialAnomalyBounds(
|
||||
{
|
||||
serviceName,
|
||||
transactionType,
|
||||
client,
|
||||
start,
|
||||
mainBuckets,
|
||||
anomalyBucketSpan
|
||||
}
|
||||
);
|
||||
|
||||
const buckets = bucketsWithInitialAnomalyBounds.map(bucket => {
|
||||
return {
|
||||
anomaly_score: bucket.anomaly_score.value,
|
||||
lower: bucket.lower.value,
|
||||
upper: bucket.upper.value
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
bucketSpanAsMillis: Math.max(bucketSize, anomalyBucketSpan) * 1000,
|
||||
buckets
|
||||
};
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { last, get } from 'lodash';
|
||||
import { getAnomalyAggs } from './get_anomaly_aggs';
|
||||
|
||||
export async function getBucketWithInitialAnomalyBounds({
|
||||
serviceName,
|
||||
transactionType,
|
||||
client,
|
||||
start,
|
||||
mainBuckets,
|
||||
anomalyBucketSpan
|
||||
}) {
|
||||
// abort if first bucket already has values for initial anomaly bounds
|
||||
if (mainBuckets[0].lower.value || !anomalyBucketSpan) {
|
||||
return mainBuckets;
|
||||
}
|
||||
|
||||
const newStart = start - anomalyBucketSpan * 1000;
|
||||
const newEnd = start;
|
||||
|
||||
const aggs = await getAnomalyAggs({
|
||||
serviceName,
|
||||
transactionType,
|
||||
intervalString: `${anomalyBucketSpan}s`,
|
||||
client,
|
||||
start: newStart,
|
||||
end: newEnd
|
||||
});
|
||||
|
||||
const firstBucketWithBounds = last(
|
||||
get(aggs, 'ml_avg_response_times.buckets', []).filter(
|
||||
bucket => bucket.lower.value
|
||||
)
|
||||
);
|
||||
|
||||
return mainBuckets.map((bucket, i) => {
|
||||
// replace first item
|
||||
if (i === 0 && firstBucketWithBounds) {
|
||||
return {
|
||||
...bucket,
|
||||
upper: { value: firstBucketWithBounds.upper.value },
|
||||
lower: { value: firstBucketWithBounds.lower.value }
|
||||
};
|
||||
}
|
||||
return bucket;
|
||||
});
|
||||
}
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { get, sortBy, round } from 'lodash';
|
||||
import mean from 'lodash.mean';
|
||||
import { getBucketSize } from '../../helpers/get_bucket_size';
|
||||
import { getAvgResponseTimeAnomalies } from './get_avg_response_time_anomalies/get_avg_response_time_anomalies';
|
||||
|
||||
export async function getTimeseriesData({
|
||||
serviceName,
|
||||
|
@ -150,15 +151,23 @@ export async function getTimeseriesData({
|
|||
bucket => bucket.key.replace(/^HTTP (\d)xx$/, '00$1') // ensure that HTTP 3xx are sorted at the top
|
||||
);
|
||||
|
||||
const avgResponseTimesAnomalies = await getAvgResponseTimeAnomalies({
|
||||
serviceName,
|
||||
transactionType,
|
||||
transactionName,
|
||||
setup
|
||||
});
|
||||
|
||||
return {
|
||||
total_hits: resp.hits.total,
|
||||
dates: dates,
|
||||
response_times: {
|
||||
avg: responseTime.avg,
|
||||
p95: responseTime.p95,
|
||||
p99: responseTime.p99
|
||||
p99: responseTime.p99,
|
||||
avg_anomalies: avgResponseTimesAnomalies
|
||||
},
|
||||
tpm_buckets: tpmBuckets,
|
||||
weighted_average: overallAvgDuration || 0
|
||||
overall_avg_duration: overallAvgDuration || 0
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue