[APM] Add kuery to apm ui (#19469) (#20669)

* Add Kuery to APM UI

* Format bool queries

* Fix test

* Add min-height

* Updated css to vars

* Added loading indicators

* Update breadcrumbs

* Update Discover links

* Add search button

* Fix readcrumb test

* Remove debounce

* Fix “undefined” kuery

* Handle missing index pattern

* Fix race condition in data fetching

* Fix legacy url encoding

* Filter out field-suggestions starting with "span*"

* Convert KuiTable to EuiTable for service overview

* Convert KuiTable to EuiTable for transaction overview

* Convert KuiTable to EuiTable for error overview

* Updated empty state messages

* Bump CI jo

* updated snapshots

* Add beta tooltip and update to EuiSearch

* Fixed issue with focus

* Submit when clicking search button

* Submit when clearing input

* Handle missing index pattern

* Remove query from spans

# Conflicts:
#	yarn.lock
This commit is contained in:
Søren Louv-Jansen 2018-07-11 16:30:25 +02:00 committed by GitHub
parent 79a8e478cb
commit 64768253d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 2632 additions and 2241 deletions

View file

@ -28,6 +28,10 @@ export function isFilterable(field) {
}
export function getFromSavedObject(savedObject) {
if (!savedObject) {
return null;
}
return {
fields: JSON.parse(savedObject.attributes.fields),
title: savedObject.attributes.title,

View file

@ -132,6 +132,7 @@
"pivotal-ui": "13.0.1",
"pluralize": "3.1.0",
"pngjs": "3.3.1",
"polished": "^1.9.2",
"prop-types": "^15.6.0",
"puid": "1.0.5",
"react": "^16.3.0",

View file

@ -130,7 +130,9 @@ function DetailView({ errorGroup, urlParams, location }) {
interval: 'auto',
query: {
language: 'lucene',
query: `${SERVICE_NAME}:"${serviceName}" AND ${ERROR_GROUP_ID}:${groupId}`
query: `${SERVICE_NAME}:"${serviceName}" AND ${ERROR_GROUP_ID}:${groupId}${
urlParams.kuery ? ` AND ${urlParams.kuery}` : ``
}`
},
sort: { '@timestamp': 'desc' }
}

View file

@ -11,6 +11,7 @@ import { get } from 'lodash';
import { HeaderLarge } from '../../shared/UIComponents';
import DetailView from './DetailView';
import Distribution from './Distribution';
import { KueryBar } from '../../shared/KueryBar';
import { EuiText, EuiBadge } from '@elastic/eui';
import {
@ -86,6 +87,8 @@ function ErrorGroupDetails({ urlParams, location }) {
)}
</HeaderLarge>
<KueryBar />
{showDetails && (
<Titles>
<EuiText>

View file

@ -1,99 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import {
unit,
px,
fontFamilyCode,
fontSizes,
truncate
} from '../../../../style/variables';
import { RelativeLink } from '../../../../utils/url';
import { KuiTableRow, KuiTableRowCell } from '@kbn/ui-framework/components';
import { RIGHT_ALIGNMENT, EuiBadge } from '@elastic/eui';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import numeral from '@elastic/numeral';
import moment from 'moment';
const GroupIdCell = styled(KuiTableRowCell)`
max-width: none;
width: 100px;
`;
const GroupIdLink = styled(RelativeLink)`
font-family: ${fontFamilyCode};
`;
const MessageAndCulpritCell = styled(KuiTableRowCell)`
${truncate(px(unit * 32))};
`;
const MessageLink = styled(RelativeLink)`
font-family: ${fontFamilyCode};
font-size: ${fontSizes.large};
${truncate('100%')};
`;
const Culprit = styled.div`
font-family: ${fontFamilyCode};
`;
const UnhandledCell = styled(KuiTableRowCell)`
max-width: none;
width: 100px;
`;
const OccurrenceCell = styled(KuiTableRowCell)`
max-width: none;
`;
function ListItem({ error, serviceName }) {
const {
groupId,
culprit,
message,
handled,
occurrenceCount,
latestOccurrenceAt
} = error;
const isUnhandled = handled === false;
const count = occurrenceCount
? numeral(occurrenceCount).format('0.[0]a')
: 'N/A';
const timeAgo = latestOccurrenceAt
? moment(latestOccurrenceAt).fromNow()
: 'N/A';
return (
<KuiTableRow>
<GroupIdCell>
<GroupIdLink path={`/${serviceName}/errors/${groupId}`}>
{groupId.slice(0, 5) || 'N/A'}
</GroupIdLink>
</GroupIdCell>
<MessageAndCulpritCell>
<TooltipOverlay content={message || 'N/A'}>
<MessageLink path={`/${serviceName}/errors/${groupId}`}>
{message || 'N/A'}
</MessageLink>
</TooltipOverlay>
<TooltipOverlay content={culprit || 'N/A'}>
<Culprit>{culprit || 'N/A'}</Culprit>
</TooltipOverlay>
</MessageAndCulpritCell>
<UnhandledCell align={RIGHT_ALIGNMENT}>
{isUnhandled && <EuiBadge color="warning">Unhandled</EuiBadge>}
</UnhandledCell>
<OccurrenceCell align={RIGHT_ALIGNMENT}>{count}</OccurrenceCell>
<OccurrenceCell align={RIGHT_ALIGNMENT}>{timeAgo}</OccurrenceCell>
</KuiTableRow>
);
}
export default ListItem;

View file

@ -6,131 +6,157 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { toQuery, fromQuery, history } from '../../../../utils/url';
import { debounce } from 'lodash';
import APMTable, {
AlignmentKuiTableHeaderCell
} from '../../../shared/APMTable/APMTable';
import ListItem from './ListItem';
import { EuiBasicTable, EuiBadge } from '@elastic/eui';
import numeral from '@elastic/numeral';
import moment from 'moment';
import {
toQuery,
fromQuery,
history,
RelativeLink
} from '../../../../utils/url';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import styled from 'styled-components';
import {
unit,
px,
fontFamilyCode,
fontSizes,
truncate
} from '../../../../style/variables';
function paginateItems({ items, pageIndex, pageSize }) {
return items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}
const GroupIdLink = styled(RelativeLink)`
font-family: ${fontFamilyCode};
`;
const MessageAndCulpritCell = styled.div`
${truncate(px(unit * 32))};
`;
const MessageLink = styled(RelativeLink)`
font-family: ${fontFamilyCode};
font-size: ${fontSizes.large};
${truncate('100%')};
`;
const Culprit = styled.div`
font-family: ${fontFamilyCode};
`;
const ITEMS_PER_PAGE = 20;
class List extends Component {
updateQuery = getNextQuery => {
state = {
page: {
index: 0,
size: 25
}
};
onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page });
const { location } = this.props;
const prevQuery = toQuery(location.search);
history.push({
...location,
search: fromQuery(getNextQuery(prevQuery))
search: fromQuery({
...toQuery(location.search),
sortField: sort.field,
sortDirection: sort.direction
})
});
};
onClickNext = () => {
const { page } = this.props.urlParams;
this.updateQuery(prevQuery => ({
...prevQuery,
page: page + 1
}));
};
onClickPrev = () => {
const { page } = this.props.urlParams;
this.updateQuery(prevQuery => ({
...prevQuery,
page: page - 1
}));
};
onFilter = debounce(q => {
this.updateQuery(prevQuery => ({
...prevQuery,
page: 0,
q
}));
}, 300);
onSort = key => {
this.updateQuery(prevQuery => ({
...prevQuery,
sortBy: key,
sortOrder: this.props.urlParams.sortOrder === 'asc' ? 'desc' : 'asc'
}));
};
render() {
const { items } = this.props;
const {
sortBy = 'latestOccurrenceAt',
sortOrder = 'desc',
page,
serviceName
} = this.props.urlParams;
const { serviceName, sortDirection, sortField } = this.props.urlParams;
const renderHead = () => {
const cells = [
{ key: 'groupId', sortable: false, label: 'Group ID' },
{ key: 'message', sortable: false, label: 'Error message and culprit' },
{ key: 'handled', sortable: false, label: '', alignRight: true },
{
key: 'occurrenceCount',
sortable: true,
label: 'Occurrences',
alignRight: true
},
{
key: 'latestOccurrenceAt',
sortable: true,
label: 'Latest occurrence',
alignRight: true
const paginatedItems = paginateItems({
items,
pageIndex: this.state.page.index,
pageSize: this.state.page.size
});
const columns = [
{
name: 'Group ID',
field: 'groupId',
sortable: false,
width: px(unit * 6),
render: groupId => {
return (
<GroupIdLink path={`/${serviceName}/errors/${groupId}`}>
{groupId.slice(0, 5) || 'N/A'}
</GroupIdLink>
);
}
].map(({ key, sortable, label, alignRight }) => (
<AlignmentKuiTableHeaderCell
key={key}
className={alignRight ? 'kuiTableHeaderCell--alignRight' : ''}
{...(sortable
? {
onSort: () => this.onSort(key),
isSorted: sortBy === key,
isSortAscending: sortOrder === 'asc'
}
: {})}
>
{label}
</AlignmentKuiTableHeaderCell>
));
return cells;
};
const renderBody = errorGroups => {
return errorGroups.map(error => {
return (
<ListItem
key={error.groupId}
serviceName={serviceName}
error={error}
/>
);
});
};
const startNumber = page * ITEMS_PER_PAGE;
const endNumber = (page + 1) * ITEMS_PER_PAGE;
const currentPageItems = items.slice(startNumber, endNumber);
},
{
name: 'Error message and culprit',
field: 'message',
sortable: false,
width: '50%',
render: (message, item) => {
return (
<MessageAndCulpritCell>
<TooltipOverlay content={message || 'N/A'}>
<MessageLink path={`/${serviceName}/errors/${item.groupId}`}>
{message || 'N/A'}
</MessageLink>
</TooltipOverlay>
<TooltipOverlay content={item.culprit || 'N/A'}>
<Culprit>{item.culprit || 'N/A'}</Culprit>
</TooltipOverlay>
</MessageAndCulpritCell>
);
}
},
{
name: '',
field: 'handled',
sortable: false,
align: 'right',
render: isUnhandled =>
isUnhandled === false && (
<EuiBadge color="warning">Unhandled</EuiBadge>
)
},
{
name: 'Occurrences',
field: 'occurrenceCount',
sortable: true,
dataType: 'number',
render: value => (value ? numeral(value).format('0.[0]a') : 'N/A')
},
{
field: 'latestOccurrenceAt',
sortable: true,
name: 'Latest occurrence',
align: 'right',
render: value => (value ? moment(value).fromNow() : 'N/A')
}
];
return (
<APMTable
defaultSearchQuery={this.props.urlParams.q}
emptyMessageHeading="No errors in the selected time range."
items={currentPageItems}
itemsPerPage={ITEMS_PER_PAGE}
onClickNext={this.onClickNext}
onClickPrev={this.onClickPrev}
onFilter={this.onFilter}
page={page}
renderBody={renderBody}
renderHead={renderHead}
totalItems={items.length}
<EuiBasicTable
noItemsMessage="No errors were found"
items={paginatedItems}
columns={columns}
pagination={{
pageIndex: this.state.page.index,
pageSize: this.state.page.size,
totalItemCount: this.props.items.length
}}
sorting={{
sort: {
field: sortField || 'latestOccurrenceAt',
direction: sortDirection || 'desc'
}
}}
onChange={this.onTableChange}
/>
);
}

View file

@ -12,6 +12,7 @@ import List from './List';
import WatcherFlyout from './Watcher/WatcherFlyOut';
import WatcherButton from './Watcher/WatcherButton';
import { ErrorGroupDetailsRequest } from '../../../store/reactReduxRequest/errorGroupList';
import { KueryBar } from '../../shared/KueryBar';
class ErrorGroupOverview extends Component {
state = {
@ -39,6 +40,8 @@ class ErrorGroupOverview extends Component {
)}
</HeaderContainer>
<KueryBar />
<TabNavigation />
<ErrorGroupDetailsRequest

View file

@ -15,7 +15,7 @@ class Breadcrumbs extends React.Component {
render() {
const { breadcrumbs, location } = this.props;
const _g = toQuery(location.search)._g;
const { _g = '', kuery = '' } = toQuery(location.search);
return (
<div className="kuiLocalBreadcrumbs">
@ -38,7 +38,9 @@ class Breadcrumbs extends React.Component {
{breadcrumb}
</span>
) : (
<a href={`#${match.url}?_g=${_g}`}>{breadcrumb}</a>
<a href={`#${match.url}?_g=${_g}&kuery=${kuery}`}>
{breadcrumb}
</a>
)}
</div>
);

View file

@ -13,7 +13,7 @@ import { toJson } from '../../../../utils/testHelpers';
function expectBreadcrumbToMatchSnapshot(route) {
const wrapper = mount(
<MemoryRouter initialEntries={[`${route}?_g=`]}>
<MemoryRouter initialEntries={[`${route}?_g=myG&kuery=myKuery`]}>
<Breadcrumbs />
</MemoryRouter>
);

View file

@ -3,7 +3,7 @@
exports[`Breadcrumbs /:serviceName 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
@ -16,12 +16,12 @@ Array [
exports[`Breadcrumbs /:serviceName/errors 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
<a
href="#/opbeans-node?_g="
href="#/opbeans-node?_g=myG&kuery=myKuery"
>
opbeans-node
</a>,
@ -34,17 +34,17 @@ Array [
exports[`Breadcrumbs /:serviceName/errors/:groupId 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
<a
href="#/opbeans-node?_g="
href="#/opbeans-node?_g=myG&kuery=myKuery"
>
opbeans-node
</a>,
<a
href="#/opbeans-node/errors?_g="
href="#/opbeans-node/errors?_g=myG&kuery=myKuery"
>
Errors
</a>,
@ -57,12 +57,12 @@ Array [
exports[`Breadcrumbs /:serviceName/transactions 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
<a
href="#/opbeans-node?_g="
href="#/opbeans-node?_g=myG&kuery=myKuery"
>
opbeans-node
</a>,
@ -75,17 +75,17 @@ Array [
exports[`Breadcrumbs /:serviceName/transactions/:transactionType 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
<a
href="#/opbeans-node?_g="
href="#/opbeans-node?_g=myG&kuery=myKuery"
>
opbeans-node
</a>,
<a
href="#/opbeans-node/transactions?_g="
href="#/opbeans-node/transactions?_g=myG&kuery=myKuery"
>
Transactions
</a>,
@ -98,22 +98,22 @@ Array [
exports[`Breadcrumbs /:serviceName/transactions/:transactionType/:transactionName 1`] = `
Array [
<a
href="#/?_g="
href="#/?_g=myG&kuery=myKuery"
>
APM
</a>,
<a
href="#/:serviceName?_g="
href="#/:serviceName?_g=myG&kuery=myKuery"
>
:serviceName
</a>,
<a
href="#/:serviceName/transactions?_g="
href="#/:serviceName/transactions?_g=myG&kuery=myKuery"
>
Transactions
</a>,
<a
href="#/:serviceName/transactions/request?_g="
href="#/:serviceName/transactions/request?_g=myG&kuery=myKuery"
>
request
</a>,

View file

@ -9,12 +9,13 @@ import styled from 'styled-components';
import { Route, Switch } from 'react-router-dom';
import { routes } from './routeConfig';
import ScrollToTopOnPathChange from './ScrollToTopOnPathChange';
import { px, units, unit } from '../../../style/variables';
import { px, units, unit, elements } from '../../../style/variables';
import ConnectRouterToRedux from '../../shared/ConnectRouterToRedux';
const MainContainer = styled.div`
min-width: ${px(unit * 50)};
padding: ${px(units.plus)};
min-height: calc(100vh - ${elements.topNav});
`;
export default function Main() {

View file

@ -1,74 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import { RelativeLink } from '../../../../utils/url';
import { KuiTableRow, KuiTableRowCell } from '@kbn/ui-framework/components';
import { fontSizes, truncate } from '../../../../style/variables';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { RIGHT_ALIGNMENT } from '@elastic/eui';
import { asMillisWithDefault } from '../../../../utils/formatters';
import numeral from '@elastic/numeral';
const ServiceNameCell = styled(KuiTableRowCell)`
width: 40%;
`;
const ServiceCell = styled(KuiTableRowCell)`
width: 15%;
`;
const AppLink = styled(RelativeLink)`
font-size: ${fontSizes.large};
${truncate('100%')};
`;
function formatString(value) {
return value || 'N/A';
}
function formatNumber(value) {
if (value === 0) {
return '0';
}
const formatted = numeral(value).format('0.0');
return formatted <= 0.1 ? '< 0.1' : formatted;
}
function ListItem({ service }) {
const {
serviceName,
agentName,
transactionsPerMinute,
errorsPerMinute,
avgResponseTime
} = service;
return (
<KuiTableRow>
<ServiceNameCell>
<TooltipOverlay content={formatString(serviceName)}>
<AppLink path={`/${serviceName}/transactions`}>
{formatString(serviceName)}
</AppLink>
</TooltipOverlay>
</ServiceNameCell>
<ServiceCell>{formatString(agentName)}</ServiceCell>
<ServiceCell align={RIGHT_ALIGNMENT}>
{asMillisWithDefault(avgResponseTime)}
</ServiceCell>
<ServiceCell align={RIGHT_ALIGNMENT}>
{formatNumber(transactionsPerMinute)} tpm
</ServiceCell>
<ServiceCell align={RIGHT_ALIGNMENT}>
{formatNumber(errorsPerMinute)} err.
</ServiceCell>
</KuiTableRow>
);
}
export default ListItem;

View file

@ -36,11 +36,7 @@ describe('ErrorGroupOverview -> List', () => {
it('should render with data', () => {
const storeState = { location: {} };
const wrapper = mountWithRouterAndStore(
<List
items={props.items}
changeServiceSorting={props.changeServiceSorting}
serviceSorting={props.serviceSorting}
/>,
<List items={props.items} />,
storeState
);

View file

@ -14,6 +14,5 @@
"errorsPerMinute": 12.6,
"avgResponseTime": 91535.42944785276
}
],
"serviceSorting": { "key": "serviceName", "descending": false }
]
}

View file

@ -6,80 +6,132 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import FilterableAPMTable from '../../../shared/APMTable/FilterableAPMTable';
import { AlignmentKuiTableHeaderCell } from '../../../shared/APMTable/APMTable';
import orderBy from 'lodash.orderby';
import styled from 'styled-components';
import numeral from '@elastic/numeral';
import { EuiBasicTable } from '@elastic/eui';
import { RelativeLink } from '../../../../utils/url';
import { fontSizes, truncate } from '../../../../style/variables';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { asMillisWithDefault } from '../../../../utils/formatters';
import ListItem from './ListItem';
function formatNumber(value) {
if (value === 0) {
return '0';
}
const formatted = numeral(value).format('0.0');
return formatted <= 0.1 ? '< 0.1' : formatted;
}
// TODO: duplicated
function paginateItems({ items, pageIndex, pageSize }) {
return items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}
function formatString(value) {
return value || 'N/A';
}
const AppLink = styled(RelativeLink)`
font-size: ${fontSizes.large};
${truncate('100%')};
`;
class List extends Component {
state = {
page: {
index: 0,
size: 10
},
sort: {
field: 'serviceName',
direction: 'asc'
}
};
onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page, sort });
};
render() {
const {
items,
changeServiceSorting,
serviceSorting,
emptyMessageHeading,
emptyMessageSubHeading
} = this.props;
const columns = [
{
field: 'serviceName',
name: 'Name',
sortable: true,
render: serviceName => (
<TooltipOverlay content={formatString(serviceName)}>
<AppLink path={`/${serviceName}/transactions`}>
{formatString(serviceName)}
</AppLink>
</TooltipOverlay>
)
},
{
field: 'agentName',
name: 'Agent',
sortable: true,
render: agentName => formatString(agentName)
},
{
field: 'avgResponseTime',
name: 'Avg. response time',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'transactionsPerMinute',
name: 'Trans. per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} tpm`
},
{
field: 'errorsPerMinute',
name: 'Errors per minute',
sortable: true,
dataType: 'number',
render: value => `${formatNumber(value)} err.`
}
];
const renderHead = () => {
const cells = [
{ key: 'serviceName', label: 'Name' },
{ key: 'agentName', label: 'Agent' },
{
key: 'avgResponseTime',
label: 'Avg. response time',
alignRight: true
},
{
key: 'transactionsPerMinute',
label: 'Trans. per minute',
alignRight: true
},
{
key: 'errorsPerMinute',
label: 'Errors per minute',
alignRight: true
}
].map(({ key, label, alignRight }) => (
<AlignmentKuiTableHeaderCell
key={key}
onSort={() => changeServiceSorting(key)}
isSorted={serviceSorting.key === key}
isSortAscending={!serviceSorting.descending}
className={alignRight ? 'kuiTableHeaderCell--alignRight' : ''}
>
{label}
</AlignmentKuiTableHeaderCell>
));
const sortedItems = orderBy(
this.props.items,
this.state.sort.field,
this.state.sort.direction
);
return cells;
};
const renderBody = services => {
return services.map(service => {
return <ListItem key={service.serviceName} service={service} />;
});
};
const renderFooterText = () => {
return items.length === 500 ? 'Only top 500 services are shown' : '';
};
const paginatedItems = paginateItems({
items: sortedItems,
pageIndex: this.state.page.index,
pageSize: this.state.page.size
});
return (
<FilterableAPMTable
searchableFields={['serviceName', 'agentName']}
items={items}
emptyMessageHeading={emptyMessageHeading}
emptyMessageSubHeading={emptyMessageSubHeading}
renderHead={renderHead}
renderBody={renderBody}
renderFooterText={renderFooterText}
<EuiBasicTable
noItemsMessage={this.props.noItemsMessage}
items={paginatedItems}
columns={columns}
pagination={{
pageIndex: this.state.page.index,
pageSize: this.state.page.size,
totalItemCount: this.props.items.length
}}
sorting={{
sort: {
field: this.state.sort.field,
direction: this.state.sort.direction
}
}}
onChange={this.onTableChange}
/>
);
}
}
List.propTypes = {
noItemsMessage: PropTypes.node,
items: PropTypes.array
};

View file

@ -8,17 +8,13 @@ import { connect } from 'react-redux';
import ServiceOverview from './view';
import { getServiceList } from '../../../store/reactReduxRequest/serviceList';
import { getUrlParams } from '../../../store/urlParams';
import sorting, { changeServiceSorting } from '../../../store/sorting';
function mapStateToProps(state = {}) {
return {
serviceList: getServiceList(state),
urlParams: getUrlParams(state),
serviceSorting: sorting(state, 'service').sorting.service
urlParams: getUrlParams(state)
};
}
const mapDispatchToProps = {
changeServiceSorting
};
const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(ServiceOverview);

View file

@ -12,19 +12,21 @@ import { KibanaLink } from '../../../utils/url';
import { EuiButton } from '@elastic/eui';
import List from './List';
import { HeaderContainer } from '../../shared/UIComponents';
import { KueryBar } from '../../shared/KueryBar';
import { ServiceListRequest } from '../../../store/reactReduxRequest/serviceList';
import EmptyMessage from '../../shared/EmptyMessage';
class ServiceOverview extends Component {
state = {
noHistoricalDataFound: false
historicalDataFound: true
};
async checkForHistoricalData({ serviceList }) {
if (serviceList.status === STATUS.SUCCESS && isEmpty(serviceList.data)) {
const result = await loadAgentStatus();
if (!result.dataFound) {
this.setState({ noHistoricalDataFound: true });
this.setState({ historicalDataFound: false });
}
}
}
@ -38,16 +40,21 @@ class ServiceOverview extends Component {
}
render() {
const { changeServiceSorting, serviceSorting, urlParams } = this.props;
const { noHistoricalDataFound } = this.state;
const { urlParams } = this.props;
const { historicalDataFound } = this.state;
const emptyMessageHeading = noHistoricalDataFound
? "Looks like you don't have any services with APM installed. Let's add some!"
: 'No services with data in the selected time range.';
const emptyMessageSubHeading = noHistoricalDataFound ? (
<SetupInstructionsLink buttonFill />
) : null;
const noItemsMessage = (
<EmptyMessage
heading={
historicalDataFound
? 'No services with data in the selected time range.'
: "Looks like you don't have any services with APM installed. Let's add some!"
}
subheading={
!historicalDataFound ? <SetupInstructionsLink buttonFill /> : null
}
/>
);
return (
<div>
@ -56,16 +63,12 @@ class ServiceOverview extends Component {
<SetupInstructionsLink />
</HeaderContainer>
<KueryBar />
<ServiceListRequest
urlParams={urlParams}
render={({ data }) => (
<List
items={data}
changeServiceSorting={changeServiceSorting}
serviceSorting={serviceSorting}
emptyMessageHeading={emptyMessageHeading}
emptyMessageSubHeading={emptyMessageSubHeading}
/>
<List items={data} noItemsMessage={noItemsMessage} />
)}
/>
</div>

View file

@ -176,10 +176,10 @@ function getPrimaryType(type) {
}
Spans.propTypes = {
location: PropTypes.object.isRequired,
agentName: PropTypes.string.isRequired,
urlParams: PropTypes.object.isRequired,
droppedSpans: PropTypes.number.isRequired
droppedSpans: PropTypes.number.isRequired,
location: PropTypes.object.isRequired,
urlParams: PropTypes.object.isRequired
};
export default Spans;

View file

@ -12,12 +12,15 @@ import { DetailsChartsRequest } from '../../../store/reactReduxRequest/detailsCh
import Charts from '../../shared/charts/TransactionCharts';
import { TransactionDistributionRequest } from '../../../store/reactReduxRequest/transactionDistribution';
import { TransactionDetailsRequest } from '../../../store/reactReduxRequest/transactionDetails';
import { KueryBar } from '../../shared/KueryBar';
function TransactionDetails({ urlParams, location }) {
return (
<div>
<HeaderLarge>{urlParams.transactionName}</HeaderLarge>
<KueryBar />
<DetailsChartsRequest
urlParams={urlParams}
render={({ data }) => (

View 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 React from 'react';
import styled from 'styled-components';
import { units, borderRadius, px, colors } from '../../../../style/variables';
const ImpactBarBackground = styled.div`
height: ${px(units.minus)};
border-radius: ${borderRadius};
background: ${colors.gray4};
width: 100%;
`;
const ImpactBar = styled.div`
height: ${px(units.minus)};
background: ${colors.blue2};
border-radius: ${borderRadius};
`;
function ImpactSparkline({ impact }) {
if (!impact && impact !== 0) {
return <div>N/A</div>;
}
return (
<ImpactBarBackground>
<ImpactBar style={{ width: `${impact}%` }} />
</ImpactBarBackground>
);
}
export default ImpactSparkline;

View file

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import { KuiTableRow, KuiTableRowCell } from '@kbn/ui-framework/components';
import {
units,
borderRadius,
px,
colors,
fontFamilyCode,
truncate
} from '../../../../style/variables';
import { RIGHT_ALIGNMENT } from '@elastic/eui';
import { RelativeLink, legacyEncodeURIComponent } from '../../../../utils/url';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { get } from 'lodash';
import { TRANSACTION_NAME } from '../../../../../common/constants';
import {
asMillisWithDefault,
asDecimal,
tpmUnit
} from '../../../../utils/formatters';
const TransactionNameCell = styled(KuiTableRowCell)`
font-family: ${fontFamilyCode};
width: 38%;
`;
const TransactionNameLink = styled(RelativeLink)`
${truncate('100%')};
`;
const TransactionKPICell = styled(KuiTableRowCell)`
max-width: none;
width: 14%;
`;
const TransactionSpacerCell = styled(KuiTableRowCell)`
max-width: none;
width: 4%;
`;
const TransactionImpactCell = styled(KuiTableRowCell)`
max-width: none;
width: 16%;
`;
const ImpactBarBackground = styled.div`
height: ${px(units.minus)};
border-radius: ${borderRadius};
background: ${colors.gray4};
`;
const ImpactBar = styled.div`
height: ${px(units.minus)};
background: ${colors.blue2};
border-radius: ${borderRadius};
`;
function ImpactSparkline({ impact }) {
if (!impact && impact !== 0) {
return <div>N/A</div>;
}
return (
<ImpactBarBackground>
<ImpactBar style={{ width: `${impact}%` }} />
</ImpactBarBackground>
);
}
function TransactionListItem({ serviceName, transaction, type, impact }) {
const transactionName = get({ transaction }, TRANSACTION_NAME);
const transactionUrl = `${serviceName}/transactions/${legacyEncodeURIComponent(
type
)}/${legacyEncodeURIComponent(transactionName)}`;
return (
<KuiTableRow>
<TransactionNameCell>
<TooltipOverlay content={transactionName || 'N/A'}>
<TransactionNameLink path={`/${transactionUrl}`}>
{transactionName || 'N/A'}
</TransactionNameLink>
</TooltipOverlay>
</TransactionNameCell>
<TransactionKPICell align={RIGHT_ALIGNMENT}>
{asMillisWithDefault(transaction.avg)}
</TransactionKPICell>
<TransactionKPICell align={RIGHT_ALIGNMENT}>
{asMillisWithDefault(transaction.p95)}
</TransactionKPICell>
<TransactionKPICell align={RIGHT_ALIGNMENT}>
{asDecimal(transaction.tpm)} {tpmUnit(type)}
</TransactionKPICell>
<TransactionSpacerCell />
<TransactionImpactCell>
<ImpactSparkline impact={impact} />
</TransactionImpactCell>
</KuiTableRow>
);
}
export default TransactionListItem;

View file

@ -6,18 +6,19 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { get } from 'lodash';
import { TRANSACTION_ID } from '../../../../../common/constants';
import styled from 'styled-components';
import { EuiBasicTable } from '@elastic/eui';
import orderBy from 'lodash.orderby';
import TooltipOverlay from '../../../shared/TooltipOverlay';
import { RelativeLink, legacyEncodeURIComponent } from '../../../../utils/url';
import {
asMillisWithDefault,
asDecimal,
tpmUnit
} from '../../../../utils/formatters';
import { KuiTableHeaderCell } from '@kbn/ui-framework/components';
import { AlignmentKuiTableHeaderCell } from '../../../shared/APMTable/APMTable';
import FilterableAPMTable from '../../../shared/APMTable/FilterableAPMTable';
import ListItem from './ListItem';
import ImpactTooltip from './ImpactTooltip';
const getRelativeImpact = (impact, impactMin, impactMax) =>
Math.max((impact - impactMin) / Math.max(impactMax - impactMin, 1) * 100, 1);
import { fontFamilyCode, truncate } from '../../../../style/variables';
import ImpactSparkline from './ImpactSparkLine';
function tpmLabel(type) {
return type === 'request' ? 'Req. per minute' : 'Trans. per minute';
@ -27,102 +28,112 @@ function avgLabel(agentName) {
return agentName === 'js-base' ? 'Page load time' : 'Avg. resp. time';
}
function paginateItems({ items, pageIndex, pageSize }) {
return items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}
const TransactionNameLink = styled(RelativeLink)`
${truncate('100%')};
font-family: ${fontFamilyCode};
`;
class List extends Component {
state = {
page: {
index: 0,
size: 25
},
sort: {
field: 'impactRelative',
direction: 'desc'
}
};
onTableChange = ({ page = {}, sort = {} }) => {
this.setState({ page, sort });
};
render() {
const {
agentName,
changeTransactionSorting,
items,
serviceName,
transactionSorting,
type
} = this.props;
const { agentName, serviceName, type } = this.props;
const renderHead = () => {
const cells = [
{ key: 'name', sortable: true, label: 'Name' },
{
key: 'avg',
sortable: true,
alignRight: true,
label: avgLabel(agentName)
},
{
key: 'p95',
sortable: true,
alignRight: true,
label: '95th percentile'
},
{
key: 'tpm',
sortable: true,
alignRight: true,
label: tpmLabel(type)
},
{ key: 'spacer', sortable: false, label: '' }
].map(({ key, sortable, label, alignRight }) => (
<AlignmentKuiTableHeaderCell
key={key}
className={alignRight ? 'kuiTableHeaderCell--alignRight' : ''}
{...(sortable
? {
onSort: () => changeTransactionSorting(key),
isSorted: transactionSorting.key === key,
isSortAscending: !transactionSorting.descending
}
: {})}
>
{label}
</AlignmentKuiTableHeaderCell>
));
const columns = [
{
field: 'name',
name: 'Name',
sortable: true,
render: transactionName => {
const transactionUrl = `${serviceName}/transactions/${legacyEncodeURIComponent(
type
)}/${legacyEncodeURIComponent(transactionName)}`;
const impactCell = (
<KuiTableHeaderCell
key={'impact'}
onSort={() => changeTransactionSorting('impact')}
isSorted={transactionSorting.key === 'impact'}
isSortAscending={!transactionSorting.descending}
>
Impact
<ImpactTooltip />
</KuiTableHeaderCell>
);
return (
<TooltipOverlay content={transactionName || 'N/A'}>
<TransactionNameLink path={`/${transactionUrl}`}>
{transactionName || 'N/A'}
</TransactionNameLink>
</TooltipOverlay>
);
}
},
{
field: 'avg',
name: avgLabel(agentName),
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'p95',
name: '95th percentile',
sortable: true,
dataType: 'number',
render: value => asMillisWithDefault(value)
},
{
field: 'tpm',
name: tpmLabel(type),
sortable: true,
dataType: 'number',
render: value => `${asDecimal(value)} ${tpmUnit(type)}`
},
{
field: 'impactRelative',
name: 'Impact',
sortable: true,
dataType: 'number',
render: value => <ImpactSparkline impact={value} />
}
];
return [...cells, impactCell];
};
const sortedItems = orderBy(
this.props.items,
this.state.sort.field,
this.state.sort.direction
);
const impacts = items.map(({ impact }) => impact);
const impactMin = Math.min(...impacts);
const impactMax = Math.max(...impacts);
const renderBody = transactions => {
return transactions.map(transaction => {
return (
<ListItem
key={get({ transaction }, TRANSACTION_ID)}
serviceName={serviceName}
type={type}
transaction={transaction}
impact={getRelativeImpact(transaction.impact, impactMin, impactMax)}
/>
);
});
};
const renderFooterText = () => {
return items.length === 500
? 'Showing first 500 results ordered by response time'
: '';
};
const paginatedItems = paginateItems({
items: sortedItems,
pageIndex: this.state.page.index,
pageSize: this.state.page.size
});
return (
<FilterableAPMTable
searchableFields={['name']}
items={items}
emptyMessageHeading="No transactions in the selected time range."
renderHead={renderHead}
renderBody={renderBody}
renderFooterText={renderFooterText}
<EuiBasicTable
noItemsMessage="No transactions found"
items={paginatedItems}
columns={columns}
pagination={{
pageIndex: this.state.page.index,
pageSize: this.state.page.size,
totalItemCount: this.props.items.length
}}
sorting={{
sort: {
field: this.state.sort.field,
direction: this.state.sort.direction
}
}}
onChange={this.onTableChange}
/>
);
}
@ -130,10 +141,15 @@ class List extends Component {
List.propTypes = {
agentName: PropTypes.string,
changeTransactionSorting: PropTypes.func.isRequired,
items: PropTypes.array,
transactionSorting: PropTypes.object.isRequired,
serviceName: PropTypes.string,
type: PropTypes.string
};
export default List;
// const renderFooterText = () => {
// return items.length === 500
// ? 'Showing first 500 results ordered by response time'
// : '';
// };

View file

@ -11,7 +11,6 @@ import { toJson } from '../../../../utils/testHelpers';
const setup = () => {
const props = {
changeTransactionSorting: () => {},
license: {
data: {
features: {
@ -19,7 +18,6 @@ const setup = () => {
}
}
},
transactionSorting: {},
hasDynamicBaseline: false,
location: {},
urlParams: { transactionType: 'request', serviceName: 'MyServiceName' }

View file

@ -10,6 +10,7 @@ exports[`TransactionOverview should not call loadTransactionList without any pro
onOpenFlyout={[Function]}
/>
</styled.div>
<Connect(KueryBarView) />
<DynamicBaselineFlyout
hasDynamicBaseline={false}
isOpen={false}

View file

@ -7,7 +7,6 @@
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';
@ -16,14 +15,11 @@ function mapStateToProps(state = {}) {
urlParams: getUrlParams(state),
hasDynamicBaseline: hasDynamicBaseline(state),
location: state.location,
transactionSorting: sorting(state, 'transaction').sorting.transaction,
license: getLicense(state)
};
}
const mapDispatchToProps = {
changeTransactionSorting
};
const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(
TransactionOverview

View file

@ -21,6 +21,7 @@ import { ServiceDetailsRequest } from '../../../store/reactReduxRequest/serviceD
import DynamicBaselineButton from './DynamicBaseline/Button';
import DynamicBaselineFlyout from './DynamicBaseline/Flyout';
import { KueryBar } from '../../shared/KueryBar';
function ServiceDetailsAndTransactionList({ urlParams, render }) {
return (
@ -62,14 +63,7 @@ class TransactionOverview extends Component {
onCloseFlyout = () => this.setState({ isFlyoutOpen: false });
render() {
const {
changeTransactionSorting,
hasDynamicBaseline,
license,
location,
transactionSorting,
urlParams
} = this.props;
const { hasDynamicBaseline, license, location, urlParams } = this.props;
const { serviceName, transactionType } = urlParams;
const mlEnabled = chrome.getInjected('mlEnabled');
@ -103,6 +97,8 @@ class TransactionOverview extends Component {
)}
</HeaderContainer>
<KueryBar />
<DynamicBaselineFlyout
hasDynamicBaseline={hasDynamicBaseline}
isOpen={this.state.isFlyoutOpen}
@ -134,11 +130,9 @@ class TransactionOverview extends Component {
return (
<List
agentName={serviceDetails.agentName}
items={transactionList}
serviceName={serviceName}
type={transactionType}
items={transactionList}
changeTransactionSorting={changeTransactionSorting}
transactionSorting={transactionSorting}
/>
);
}}
@ -149,11 +143,9 @@ class TransactionOverview extends Component {
}
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
};

View file

@ -1,135 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import {
KuiControlledTable,
KuiEmptyTablePromptPanel,
KuiPager,
KuiTable,
KuiTableBody,
KuiTableHeader,
KuiTableHeaderCell,
KuiToolBar,
KuiToolBarFooter,
KuiToolBarFooterSection,
KuiToolBarSearchBox,
KuiToolBarSection
} from '@kbn/ui-framework/components';
import EmptyMessage from '../EmptyMessage';
import { fontSizes, colors } from '../../../style/variables';
export const FooterText = styled.div`
font-size: ${fontSizes.small};
color: ${colors.gray3};
`;
export const AlignmentKuiTableHeaderCell = styled(KuiTableHeaderCell)`
max-width: none;
&.kuiTableHeaderCell--alignRight > button > span {
justify-content: flex-end;
}
`; // Fixes alignment for sortable KuiTableHeaderCell children
function APMTable({
defaultSearchQuery,
emptyMessageHeading,
emptyMessageSubHeading,
items,
itemsPerPage,
onClickNext,
onClickPrev,
onFilter,
inputPlaceholder,
page,
renderBody,
renderFooterText,
renderHead,
totalItems
}) {
const startNumber = page * itemsPerPage;
const endNumber = (page + 1) * itemsPerPage;
const pagination = (
<KuiToolBarSection>
<KuiPager
startNumber={startNumber}
endNumber={Math.min(endNumber, totalItems)}
totalItems={totalItems}
hasNextPage={endNumber < totalItems}
hasPreviousPage={page > 0}
onNextPage={onClickNext}
onPreviousPage={onClickPrev}
/>
</KuiToolBarSection>
);
return (
<KuiControlledTable>
<KuiToolBar>
<KuiToolBarSearchBox
defaultValue={defaultSearchQuery}
onClick={e => e.stopPropagation()}
onFilter={onFilter}
placeholder={inputPlaceholder}
/>
{pagination}
</KuiToolBar>
{items.length === 0 && (
<KuiEmptyTablePromptPanel>
<EmptyMessage
heading={emptyMessageHeading}
subheading={emptyMessageSubHeading}
/>
</KuiEmptyTablePromptPanel>
)}
{items.length > 0 && (
<KuiTable>
<KuiTableHeader>{renderHead()}</KuiTableHeader>
<KuiTableBody>{renderBody(items)}</KuiTableBody>
</KuiTable>
)}
<KuiToolBarFooter>
<KuiToolBarFooterSection>
<FooterText>{renderFooterText()}</FooterText>
</KuiToolBarFooterSection>
{pagination}
</KuiToolBarFooter>
</KuiControlledTable>
);
}
APMTable.propTypes = {
defaultSearchQuery: PropTypes.string,
emptyMessageHeading: PropTypes.string,
items: PropTypes.array,
itemsPerPage: PropTypes.number.isRequired,
onClickNext: PropTypes.func.isRequired,
onClickPrev: PropTypes.func.isRequired,
onFilter: PropTypes.func.isRequired,
page: PropTypes.number.isRequired,
renderBody: PropTypes.func.isRequired,
renderFooterText: PropTypes.func,
renderHead: PropTypes.func.isRequired,
totalItems: PropTypes.number.isRequired
};
APMTable.defaultProps = {
items: [],
page: 0,
renderFooterText: () => {},
totalItems: 0
};
export default APMTable;

View file

@ -1,94 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import APMTable from './APMTable';
const ITEMS_PER_PAGE = 20;
class FilterableAPMTable extends Component {
state = { searchQuery: '', page: 0 };
onFilter = searchQuery => {
this.setState({ searchQuery, page: 0 });
};
onClickNext = () => {
this.setState(state => ({
page: state.page + 1
}));
};
onClickPrev = () => {
this.setState(state => ({
page: state.page - 1
}));
};
render() {
const {
emptyMessageHeading,
emptyMessageSubHeading,
items,
renderBody,
renderFooterText,
renderHead,
searchableFields
} = this.props;
const startNumber = this.state.page * ITEMS_PER_PAGE;
const endNumber = (this.state.page + 1) * ITEMS_PER_PAGE;
const filteredItems = items.filter(item => {
const isEmpty = this.state.searchQuery === '';
const isMatch = searchableFields.some(property => {
return (
item[property] &&
item[property]
.toLowerCase()
.includes(this.state.searchQuery.toLowerCase())
);
});
return isEmpty || isMatch;
});
const currentPageItems = filteredItems.slice(startNumber, endNumber);
return (
<APMTable
emptyMessageHeading={emptyMessageHeading}
emptyMessageSubHeading={emptyMessageSubHeading}
items={currentPageItems}
itemsPerPage={ITEMS_PER_PAGE}
onClickNext={this.onClickNext}
onClickPrev={this.onClickPrev}
onFilter={this.onFilter}
inputPlaceholder="Filter..."
page={this.state.page}
renderBody={renderBody}
renderHead={renderHead}
renderFooterText={renderFooterText}
totalItems={items.length}
/>
);
}
}
FilterableAPMTable.propTypes = {
emptyMessageHeading: PropTypes.string,
items: PropTypes.array,
renderBody: PropTypes.func.isRequired,
renderFooterText: PropTypes.func,
renderHead: PropTypes.func.isRequired,
searchableFields: PropTypes.array
};
FilterableAPMTable.defaultProps = {
searchableFields: [],
items: [],
renderFooterText: () => {}
};
export default FilterableAPMTable;

View 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.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class ClickOutside extends Component {
componentDidMount() {
document.addEventListener('mousedown', this.onClick);
}
componentWillUnmount() {
document.removeEventListener('mousedown', this.onClick);
}
setNodeRef = node => {
this.nodeRef = node;
};
onClick = event => {
if (this.nodeRef && !this.nodeRef.contains(event.target)) {
this.props.onClickOutside();
}
};
render() {
const { onClickOutside, ...restProps } = this.props;
return (
<div ref={this.setNodeRef} {...restProps}>
{this.props.children}
</div>
);
}
}
ClickOutside.propTypes = {
onClickOutside: PropTypes.func.isRequired
};

View file

@ -0,0 +1,127 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { EuiIcon } from '@elastic/eui';
import {
colors,
fontFamilyCode,
px,
units,
fontSizes,
unit
} from '../../../../style/variables';
import { tint } from 'polished';
function getIconColor(type) {
switch (type) {
case 'field':
return colors.apmOrange;
case 'value':
return colors.teal;
case 'operator':
return colors.apmBlue;
case 'conjunction':
return colors.apmPurple;
case 'recentSearch':
return colors.gray3;
}
}
const Description = styled.div`
color: ${colors.gray2};
p {
display: inline;
span {
font-family: ${fontFamilyCode};
color: ${colors.black};
padding: 0 ${px(units.quarter)};
display: inline-block;
}
}
`;
const ListItem = styled.li`
font-size: ${fontSizes.small};
height: ${px(units.double)};
align-items: center;
display: flex;
background: ${props => (props.selected ? colors.gray5 : 'initial')};
cursor: pointer;
border-radius: ${px(units.quarter)};
${Description} {
p span {
background: ${props => (props.selected ? colors.white : colors.gray5)};
}
}
`;
const Icon = styled.div`
flex: 0 0 ${px(units.double)};
background: ${props => tint(0.1, getIconColor(props.type))};
color: ${props => getIconColor(props.type)};
width: 100%;
height: 100%;
text-align: center;
line-height: ${px(units.double)};
`;
const TextValue = styled.div`
flex: 0 0 ${px(unit * 16)};
color: ${colors.black2};
padding: 0 ${px(units.half)};
`;
function getEuiIconType(type) {
switch (type) {
case 'field':
return 'kqlField';
case 'value':
return 'kqlValue';
case 'recentSearch':
return 'search';
case 'conjunction':
return 'kqlSelector';
case 'operator':
return 'kqlOperand';
default:
throw new Error('Unknown type', type);
}
}
function Suggestion(props) {
return (
<ListItem
innerRef={props.innerRef}
selected={props.selected}
onClick={() => props.onClick(props.suggestion)}
onMouseEnter={props.onMouseEnter}
>
<Icon type={props.suggestion.type}>
<EuiIcon type={getEuiIconType(props.suggestion.type)} />
</Icon>
<TextValue>{props.suggestion.text}</TextValue>
<Description
dangerouslySetInnerHTML={{ __html: props.suggestion.description }}
/>
</ListItem>
);
}
Suggestion.propTypes = {
onClick: PropTypes.func.isRequired,
onMouseEnter: PropTypes.func.isRequired,
selected: PropTypes.bool,
suggestion: PropTypes.object.isRequired,
innerRef: PropTypes.func.isRequired
};
export default Suggestion;

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
import Suggestion from './Suggestion';
import { units, colors, px, unit } from '../../../../style/variables';
const List = styled.ul`
width: 100%;
border: 1px solid ${colors.gray4};
border-radius: ${px(units.quarter)};
box-shadow: 0px ${px(units.quarter)} ${px(units.double)} rgba(0, 0, 0, 0.1);
position: absolute;
background: #fff;
z-index: 10;
left: 0;
max-height: ${px(unit * 20)};
overflow: scroll;
`;
class Suggestions extends Component {
childNodes = [];
scrollIntoView = () => {
const parent = this.parentNode;
const child = this.childNodes[this.props.index];
if (this.props.index == null || !parent || !child) {
return;
}
const scrollTop = Math.max(
Math.min(parent.scrollTop, child.offsetTop),
child.offsetTop + child.offsetHeight - parent.offsetHeight
);
parent.scrollTop = scrollTop;
};
componentDidUpdate(prevProps) {
if (prevProps.index !== this.props.index) {
this.scrollIntoView();
}
}
render() {
if (!this.props.show || isEmpty(this.props.suggestions)) {
return null;
}
const suggestions = this.props.suggestions.map((suggestion, index) => {
const key = suggestion + '_' + index;
return (
<Suggestion
innerRef={node => (this.childNodes[index] = node)}
selected={index === this.props.index}
suggestion={suggestion}
onClick={this.props.onClick}
onMouseEnter={() => this.props.onMouseEnter(index)}
key={key}
/>
);
});
return (
<List innerRef={node => (this.parentNode = node)}>{suggestions}</List>
);
}
}
Suggestions.propTypes = {
index: PropTypes.number,
onClick: PropTypes.func.isRequired,
onMouseEnter: PropTypes.func.isRequired,
show: PropTypes.bool,
suggestions: PropTypes.array.isRequired
};
export default Suggestions;

View file

@ -0,0 +1,243 @@
/*
* 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 Suggestions from './Suggestions';
import ClickOutside from './ClickOutside';
import {
EuiButton,
EuiFieldSearch,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiIconTip
} from '@elastic/eui';
const KEY_CODES = {
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
ENTER: 13,
ESC: 27,
TAB: 9
};
export class Typeahead extends Component {
state = {
isSuggestionsVisible: false,
index: null,
value: '',
inputIsPristine: true
};
static getDerivedStateFromProps(props, state) {
if (state.inputIsPristine && props.initialValue) {
return {
value: props.initialValue
};
}
return null;
}
incrementIndex = currentIndex => {
let nextIndex = currentIndex + 1;
if (currentIndex === null || nextIndex >= this.props.suggestions.length) {
nextIndex = 0;
}
this.setState({ index: nextIndex });
};
decrementIndex = currentIndex => {
let previousIndex = currentIndex - 1;
if (previousIndex < 0) {
previousIndex = null;
}
this.setState({ index: previousIndex });
};
onKeyUp = event => {
const { selectionStart } = event.target;
const { value } = this.state;
switch (event.keyCode) {
case KEY_CODES.LEFT:
this.setState({ isSuggestionsVisible: true });
this.props.onChange(value, selectionStart);
break;
case KEY_CODES.RIGHT:
this.setState({ isSuggestionsVisible: true });
this.props.onChange(value, selectionStart);
break;
}
};
onKeyDown = event => {
const { isSuggestionsVisible, index, value } = this.state;
switch (event.keyCode) {
case KEY_CODES.DOWN:
event.preventDefault();
if (isSuggestionsVisible) {
this.incrementIndex(index);
} else {
this.setState({ isSuggestionsVisible: true, index: 0 });
}
break;
case KEY_CODES.UP:
event.preventDefault();
if (isSuggestionsVisible) {
this.decrementIndex(index);
}
break;
case KEY_CODES.ENTER:
event.preventDefault();
if (isSuggestionsVisible && this.props.suggestions[index]) {
this.selectSuggestion(this.props.suggestions[index]);
} else {
this.setState({ isSuggestionsVisible: false });
this.props.onSubmit(value);
}
break;
case KEY_CODES.ESC:
event.preventDefault();
this.setState({ isSuggestionsVisible: false });
break;
case KEY_CODES.TAB:
this.setState({ isSuggestionsVisible: false });
break;
}
};
selectSuggestion = suggestion => {
const nextInputValue =
this.state.value.substr(0, suggestion.start) +
suggestion.text +
this.state.value.substr(suggestion.end);
this.setState({ value: nextInputValue, index: null });
this.props.onChange(nextInputValue, nextInputValue.length);
};
onClickOutside = () => {
this.setState({ isSuggestionsVisible: false });
};
onChangeInputValue = event => {
const { value, selectionStart } = event.target;
const hasValue = Boolean(value.trim());
this.setState({
value,
inputIsPristine: false,
isSuggestionsVisible: hasValue,
index: null
});
if (!hasValue) {
this.props.onSubmit(value);
}
this.props.onChange(value, selectionStart);
};
onClickInput = event => {
const { selectionStart } = event.target;
this.props.onChange(this.state.value, selectionStart);
};
onClickSuggestion = suggestion => {
this.selectSuggestion(suggestion);
this.inputRef.focus();
};
onMouseEnterSuggestion = index => {
this.setState({ index });
};
onSubmit = () => {
this.props.onSubmit(this.state.value);
this.setState({ isSuggestionsVisible: false });
};
render() {
return (
<ClickOutside
onClickOutside={this.onClickOutside}
style={{ position: 'relative' }}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem style={{ position: 'relative' }}>
<EuiFieldSearch
fullWidth
style={{
backgroundImage: 'none'
}}
placeholder="Search transactions or errors… (i.e. transaction.duration.us => 100000)"
inputRef={node => {
if (node) {
this.inputRef = node;
}
}}
disabled={this.props.disabled}
value={this.state.value}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onChange={this.onChangeInputValue}
onClick={this.onClickInput}
autoComplete="off"
spellCheck={false}
/>
{this.props.isLoading && (
<EuiProgress
size="xs"
color="accent"
position="absolute"
style={{
bottom: 0,
top: 'initial'
}}
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={this.onSubmit} disabled={this.props.disabled}>
Search
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
content="The Query bar feature is still in beta. Help us report any issues or bugs by using the APM feedback link in the top."
position="left"
/>
</EuiFlexItem>
</EuiFlexGroup>
<Suggestions
show={this.state.isSuggestionsVisible}
suggestions={this.props.suggestions}
index={this.state.index}
onClick={this.onClickSuggestion}
onMouseEnter={this.onMouseEnterSuggestion}
/>
</ClickOutside>
);
}
}
Typeahead.propTypes = {
initialValue: PropTypes.string,
isLoading: PropTypes.bool,
disabled: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
suggestions: PropTypes.array.isRequired
};
Typeahead.defaultProps = {
isLoading: false,
disabled: false,
suggestions: []
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
TRANSACTION_TYPE,
ERROR_GROUP_ID,
PROCESSOR_EVENT,
TRANSACTION_NAME,
SERVICE_NAME
} from '../../../../common/constants';
export function getBoolFilter(urlParams) {
const boolFilter = [
{
range: {
'@timestamp': {
gte: new Date(urlParams.start).getTime(),
lte: new Date(urlParams.end).getTime(),
format: 'epoch_millis'
}
}
}
];
if (urlParams.serviceName) {
boolFilter.push({
term: { [SERVICE_NAME]: urlParams.serviceName }
});
}
switch (urlParams.processorEvent) {
case 'transaction':
boolFilter.push({
term: { [PROCESSOR_EVENT]: 'transaction' }
});
if (urlParams.transactionName) {
boolFilter.push({
term: { [`${TRANSACTION_NAME}.keyword`]: urlParams.transactionName }
});
}
if (urlParams.transactionType) {
boolFilter.push({
term: { [TRANSACTION_TYPE]: urlParams.transactionType }
});
}
break;
case 'error':
boolFilter.push({
term: { [PROCESSOR_EVENT]: 'error' }
});
if (urlParams.errorGroupId) {
boolFilter.push({
term: { [ERROR_GROUP_ID]: urlParams.errorGroupId }
});
}
break;
default:
boolFilter.push({
bool: {
should: [
{ term: { [PROCESSOR_EVENT]: 'error' } },
{ term: { [PROCESSOR_EVENT]: 'transaction' } }
]
}
});
}
return boolFilter;
}

View file

@ -6,18 +6,21 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { uniqueId, startsWith } from 'lodash';
import { EuiCallOut } from '@elastic/eui';
import {
history,
fromQuery,
toQuery,
legacyEncodeURIComponent
} from '../../../utils/url';
import { debounce } from 'lodash';
import { EuiFieldSearch } from '@elastic/eui';
import { Typeahead } from './Typeahead';
import { getAPMIndexPattern } from '../../../services/rest/savedObjects';
import { convertKueryToEsQuery, getSuggestions } from '../../../services/kuery';
import styled from 'styled-components';
import { getBoolFilter } from './get_bool_filter';
const Container = styled.div`
margin-bottom: 10px;
`;
@ -25,36 +28,48 @@ const Container = styled.div`
class KueryBarView extends Component {
state = {
indexPattern: null,
inputValue: this.props.urlParams.kuery || ''
suggestions: [],
isLoadingIndexPattern: true,
isLoadingSuggestions: false
};
componentDidMount() {
getAPMIndexPattern().then(indexPattern => {
this.setState({ indexPattern });
});
async componentDidMount() {
const indexPattern = await getAPMIndexPattern();
this.setState({ indexPattern, isLoadingIndexPattern: false });
}
componentWillReceiveProps(nextProps) {
const kuery = nextProps.urlParams.kuery;
if (kuery && !this.state.inputValue) {
this.setState({ inputValue: kuery });
}
}
updateUrl = debounce(kuery => {
const { location } = this.props;
onChange = async (inputValue, selectionStart) => {
const { indexPattern } = this.state;
const { urlParams } = this.props;
this.setState({ suggestions: [], isLoadingSuggestions: true });
if (!indexPattern) {
return;
}
getSuggestions(kuery, indexPattern).then(
suggestions => console.log(suggestions.map(suggestion => suggestion.text)) // eslint-disable-line no-console
);
const currentRequest = uniqueId();
this.currentRequest = currentRequest;
const boolFilter = getBoolFilter(urlParams);
try {
const res = convertKueryToEsQuery(kuery, indexPattern);
const suggestions = (await getSuggestions(
inputValue,
selectionStart,
indexPattern,
boolFilter
)).filter(suggestion => !startsWith(suggestion.text, 'span.'));
if (currentRequest !== this.currentRequest) {
return;
}
this.setState({ suggestions, isLoadingSuggestions: false });
} catch (e) {
console.error('Error while fetching suggestions', e);
}
};
onSubmit = inputValue => {
const { indexPattern } = this.state;
const { location } = this.props;
try {
const res = convertKueryToEsQuery(inputValue, indexPattern);
if (!res) {
return;
}
@ -63,29 +78,44 @@ class KueryBarView extends Component {
...location,
search: fromQuery({
...toQuery(this.props.location.search),
kuery: legacyEncodeURIComponent(kuery)
kuery: legacyEncodeURIComponent(inputValue.trim())
})
});
} catch (e) {
console.log('Invalid kuery syntax'); // eslint-disable-line no-console
}
}, 200);
onChange = event => {
const kuery = event.target.value;
this.setState({ inputValue: kuery });
this.updateUrl(kuery);
};
render() {
const indexPatternMissing =
!this.state.isLoadingIndexPattern && !this.state.indexPattern;
return (
<Container>
<EuiFieldSearch
placeholder="Search... (Example: transaction.duration.us > 10000)"
fullWidth
<Typeahead
disabled={indexPatternMissing}
isLoading={this.state.isLoadingSuggestions}
initialValue={this.props.urlParams.kuery}
onChange={this.onChange}
value={this.state.inputValue}
onSubmit={this.onSubmit}
suggestions={this.state.suggestions}
/>
{indexPatternMissing && (
<EuiCallOut
style={{ display: 'inline-block', marginTop: '10px' }}
title={
<div>
There&#39;s no APM index pattern available. To use the Query
bar, please choose to import the APM index pattern in the{' '}
<a href="/app/kibana#/home/tutorial/apm">Setup Instructions.</a>
</div>
}
color="warning"
iconType="alert"
size="s"
/>
)}
</Container>
);
}

View file

@ -15,18 +15,24 @@ export function convertKueryToEsQuery(kuery, indexPattern) {
return toElasticsearchQuery(ast, indexPattern);
}
export async function getSuggestions(query, apmIndexPattern) {
export async function getSuggestions(
query,
selectionStart,
apmIndexPattern,
boolFilter
) {
const config = {
get: () => true
};
const getKuerySuggestions = getSuggestionsProvider({
config,
indexPatterns: [apmIndexPattern]
indexPatterns: [apmIndexPattern],
boolFilter
});
return getKuerySuggestions({
query,
selectionStart: query.length,
selectionEnd: query.length
selectionStart,
selectionEnd: selectionStart
});
}

View file

@ -33,6 +33,11 @@ export async function getEncodedEsQuery(kuery) {
}
const indexPattern = await getAPMIndexPattern();
if (!indexPattern) {
return;
}
const esFilterQuery = convertKueryToEsQuery(kuery, indexPattern);
return encodeURIComponent(JSON.stringify(esFilterQuery));
}
@ -95,19 +100,12 @@ export async function loadTransactionDistribution({
});
}
export async function loadSpans({
serviceName,
start,
end,
transactionId,
kuery
}) {
export async function loadSpans({ serviceName, start, end, transactionId }) {
return callApi({
pathname: `/api/apm/services/${serviceName}/transactions/${transactionId}/spans`,
query: {
start,
end,
esFilterQuery: await getEncodedEsQuery(kuery)
end
}
});
}
@ -165,9 +163,8 @@ export async function loadErrorGroupList({
end,
kuery,
size,
q,
sortBy,
sortOrder
sortField,
sortDirection
}) {
return callApi({
pathname: `/api/apm/services/${serviceName}/errors`,
@ -175,9 +172,8 @@ export async function loadErrorGroupList({
start,
end,
size,
q,
sortBy,
sortOrder,
sortField,
sortDirection,
esFilterQuery: await getEncodedEsQuery(kuery)
}
});

View file

@ -11,10 +11,6 @@ describe('root reducer', () => {
expect(reducer(undefined, {})).toEqual({
location: { hash: '', pathname: '', search: '' },
reactReduxRequest: {},
sorting: {
service: { descending: false, key: 'serviceName' },
transaction: { descending: true, key: 'impact' }
},
urlParams: {}
});
});

View file

@ -25,6 +25,7 @@ describe('urlParams', () => {
page: 0,
serviceName: 'myServiceName',
spanId: 10,
processorEvent: 'transaction',
transactionId: 'myTransactionId',
transactionName: 'myTransactionName',
detailTab: 'request',

View file

@ -18,7 +18,14 @@ export function getErrorGroupList(state) {
}
export function ErrorGroupDetailsRequest({ urlParams, render }) {
const { serviceName, start, end, q, sortBy, sortOrder, kuery } = urlParams;
const {
serviceName,
start,
end,
sortField,
sortDirection,
kuery
} = urlParams;
if (!(serviceName && start && end)) {
return null;
@ -28,7 +35,7 @@ export function ErrorGroupDetailsRequest({ urlParams, render }) {
<Request
id={ID}
fn={loadErrorGroupList}
args={[{ serviceName, start, end, q, sortBy, sortOrder, kuery }]}
args={[{ serviceName, start, end, sortField, sortDirection, kuery }]}
selector={getErrorGroupList}
render={render}
/>

View file

@ -5,8 +5,6 @@
*/
import React from 'react';
import orderBy from 'lodash.orderby';
import { createSelector } from 'reselect';
import { loadServiceList } from '../../services/rest/apm';
import { Request } from 'react-redux-request';
import { createInitialDataSelector } from './helpers';
@ -15,18 +13,9 @@ const ID = 'serviceList';
const INITIAL_DATA = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
export const getServiceList = createSelector(
state => withInitialData(state.reactReduxRequest[ID]),
state => state.sorting.service,
(serviceList, serviceSorting) => {
const { key: sortKey, descending } = serviceSorting;
return {
...serviceList,
data: orderBy(serviceList.data, sortKey, descending ? 'desc' : 'asc')
};
}
);
export function getServiceList(state) {
return withInitialData(state.reactReduxRequest[ID]);
}
export function ServiceListRequest({ urlParams, render }) {
const { start, end, kuery } = urlParams;

View file

@ -5,7 +5,6 @@
*/
import React from 'react';
import orderBy from 'lodash.orderby';
import { createSelector } from 'reselect';
import { Request } from 'react-redux-request';
import { loadTransactionList } from '../../services/rest/apm';
@ -15,19 +14,40 @@ const ID = 'transactionList';
const INITIAL_DATA = [];
const withInitialData = createInitialDataSelector(INITIAL_DATA);
const getRelativeImpact = (impact, impactMin, impactMax) =>
Math.max((impact - impactMin) / Math.max(impactMax - impactMin, 1) * 100, 1);
function getWithRelativeImpact(items) {
const impacts = items.map(({ impact }) => impact);
const impactMin = Math.min(...impacts);
const impactMax = Math.max(...impacts);
return items.map(item => {
return {
...item,
impactRelative: getRelativeImpact(item.impact, impactMin, impactMax)
};
});
}
export const getTransactionList = createSelector(
state => withInitialData(state.reactReduxRequest[ID]),
state => state.sorting.transaction,
(transactionList = {}, transactionSorting) => {
const { key: sortKey, descending } = transactionSorting;
transactionList => {
return {
...transactionList,
data: orderBy(transactionList.data, sortKey, descending ? 'desc' : 'asc')
data: getWithRelativeImpact(transactionList.data)
};
}
);
// export function getTransactionList(state) {
// const transactionList = withInitialData(state.reactReduxRequest[ID]);
// return {
// ...transactionList,
// };
// }
export function TransactionListRequest({ urlParams, render }) {
const { serviceName, start, end, transactionType, kuery } = urlParams;

View file

@ -6,13 +6,11 @@
import { combineReducers } from 'redux';
import location from './location';
import sorting from './sorting';
import urlParams from './urlParams';
import { reducer } from 'react-redux-request';
const rootReducer = combineReducers({
location,
sorting,
urlParams,
reactReduxRequest: reducer
});

View file

@ -1,57 +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.
*/
export const SORTING_CHANGE = 'SORTING_CHANGE';
const INITIAL_STATE = {
transaction: {
key: 'impact',
descending: true
},
service: {
key: 'serviceName',
descending: false
}
};
const INITIALLY_DESCENDING = {
transaction: ['name'],
service: ['serviceName']
};
function getSorting(state, action) {
const isInitiallyDescending = INITIALLY_DESCENDING[action.section].includes(
action.key
);
const descending =
state.key === action.key ? !state.descending : isInitiallyDescending;
return { ...state, key: action.key, descending };
}
export default function sorting(state = INITIAL_STATE, action) {
switch (action.type) {
case SORTING_CHANGE: {
return {
...state,
[action.section]: getSorting(state[action.section], action)
};
}
default:
return state;
}
}
export const changeTransactionSorting = key => ({
type: SORTING_CHANGE,
key,
section: 'transaction'
});
export const changeServiceSorting = key => ({
type: SORTING_CHANGE,
key,
section: 'service'
});

View file

@ -26,6 +26,7 @@ function urlParams(state = {}, action) {
switch (action.type) {
case LOCATION_UPDATE: {
const {
processorEvent,
serviceName,
transactionType,
transactionName,
@ -37,9 +38,8 @@ function urlParams(state = {}, action) {
detailTab,
spanId,
page,
sortBy,
sortOrder,
q,
sortDirection,
sortField,
kuery
} = toQuery(action.location.search);
@ -47,9 +47,8 @@ function urlParams(state = {}, action) {
...state,
// query params
q,
sortBy,
sortOrder,
sortDirection,
sortField,
page: toNumber(page) || 0,
transactionId,
detailTab,
@ -57,6 +56,7 @@ function urlParams(state = {}, action) {
kuery: legacyDecodeURIComponent(kuery),
// path params
processorEvent,
serviceName,
transactionType: legacyDecodeURIComponent(transactionType),
transactionName: legacyDecodeURIComponent(transactionName),
@ -89,12 +89,14 @@ function getPathParams(pathname) {
switch (pageName) {
case 'transactions':
return {
processorEvent: 'transaction',
serviceName: paths[0],
transactionType: paths[2],
transactionName: paths[3]
};
case 'errors':
return {
processorEvent: 'error',
serviceName: paths[0],
errorGroupId: paths[2]
};

View file

@ -90,3 +90,8 @@ export function truncate(width) {
text-overflow: ellipsis;
`;
}
// height of specific elements
export const elements = {
topNav: '29px'
};

View file

@ -133,15 +133,11 @@ export const KibanaLink = withLocation(KibanaLinkComponent);
// Angular decodes encoded url tokens like "%2F" to "/" which causes the route to change.
// It was supposedly fixed in https://github.com/angular/angular.js/commit/1b779028fdd339febaa1fff5f3bd4cfcda46cc09 but still seeing the issue
export function legacyEncodeURIComponent(url) {
return (
url && encodeURIComponent(url.replace(/\//g, '~2F').replace(/ /g, '~20'))
);
return url && encodeURIComponent(url).replace(/%/g, '~');
}
export function legacyDecodeURIComponent(url) {
return (
url && decodeURIComponent(url.replace(/~2F/g, '/').replace(/~20/g, ' '))
);
return url && decodeURIComponent(url.replace(/~/g, '%'));
}
export function ExternalLink(props) {

View file

@ -17,9 +17,8 @@ import { get } from 'lodash';
export async function getErrorGroups({
serviceName,
q,
sortBy,
sortOrder = 'desc',
sortField,
sortDirection = 'desc',
setup
}) {
const { start, end, esFilterQuery, client, config } = setup;
@ -50,7 +49,7 @@ export async function getErrorGroups({
terms: {
field: ERROR_GROUP_ID,
size: 500,
order: { _count: sortOrder }
order: { _count: sortDirection }
},
aggs: {
sample: {
@ -78,9 +77,9 @@ export async function getErrorGroups({
}
// sort buckets by last occurence of error
if (sortBy === 'latestOccurrenceAt') {
if (sortField === 'latestOccurrenceAt') {
params.body.aggs.error_groups.terms.order = {
max_timestamp: sortOrder
max_timestamp: sortDirection
};
params.body.aggs.error_groups.aggs.max_timestamp = {
@ -88,23 +87,6 @@ export async function getErrorGroups({
};
}
// match query against error fields
if (q) {
params.body.query.bool.must = [
{
simple_query_string: {
fields: [
ERROR_EXC_MESSAGE,
ERROR_LOG_MESSAGE,
ERROR_CULPRIT,
ERROR_GROUP_ID
],
query: q
}
}
];
}
const resp = await client('search', params);
const hits = get(resp, 'aggregations.error_groups.buckets', []).map(
bucket => {

View file

@ -12,7 +12,7 @@ import {
} from '../../../../common/constants';
async function getSpans({ transactionId, setup }) {
const { start, end, esFilterQuery, client, config } = setup;
const { start, end, client, config } = setup;
const params = {
index: config.get('xpack.apm.indexPattern'),
@ -47,10 +47,6 @@ async function getSpans({ transactionId, setup }) {
}
};
if (esFilterQuery) {
params.body.query.bool.filter.push(esFilterQuery);
}
const resp = await client('search', params);
return {
span_types: resp.aggregations.types.buckets.map(bucket => ({

View file

@ -28,22 +28,20 @@ export function initErrorsApi(server) {
pre,
validate: {
query: withDefaultValidators({
q: Joi.string().allow(''),
sortBy: Joi.string(),
sortOrder: Joi.string()
sortField: Joi.string(),
sortDirection: Joi.string()
})
}
},
handler: (req, reply) => {
const { setup } = req.pre;
const { serviceName } = req.params;
const { q, sortBy, sortOrder } = req.query;
const { sortField, sortDirection } = req.query;
return getErrorGroups({
serviceName,
q,
sortBy,
sortOrder,
sortField,
sortDirection,
setup
})
.then(reply)

View file

@ -5921,6 +5921,10 @@ pngjs@3.3.1, pngjs@^3.0.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.3.1.tgz#8e14e6679ee7424b544334c3b2d21cea6d8c209a"
polished@^1.9.2:
version "1.9.2"
resolved "https://registry.yarnpkg.com/polished/-/polished-1.9.2.tgz#d705cac66f3a3ed1bd38aad863e2c1e269baf6b6"
popper.js@^1.14.1:
version "1.14.3"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.3.tgz#1438f98d046acf7b4d78cd502bf418ac64d4f095"

View file

@ -10123,6 +10123,10 @@ podium@3.x.x:
hoek "5.x.x"
joi "13.x.x"
polished@^1.9.2:
version "1.9.3"
resolved "https://registry.yarnpkg.com/polished/-/polished-1.9.3.tgz#d61b8a0c4624efe31e2583ff24a358932b6b75e1"
popper.js@^1.14.1:
version "1.14.3"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.3.tgz#1438f98d046acf7b4d78cd502bf418ac64d4f095"