[APM] Implement nest level expand/collapse toggle for each span row (#75259)

* returning an waterfallTransaction

* fixing style

* fixing unit test

* fixing style

* addressing PR comment

* addressing PR comment

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2020-08-25 15:39:57 +01:00 committed by GitHub
parent c3b6745e3d
commit 75232a74f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1361 additions and 106 deletions

View file

@ -40,7 +40,6 @@ const Container = styled.div<IContainerStyleProps>`
padding-bottom: ${px(units.plus)};
margin-right: ${(props) => px(props.timelineMargins.right)};
margin-left: ${(props) => px(props.timelineMargins.left)};
border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
background-color: ${({ isSelected, theme }) =>
isSelected ? theme.eui.euiColorLightestShade : 'initial'};
cursor: pointer;
@ -191,7 +190,10 @@ export function WaterfallItem({
type={item.docType}
timelineMargins={timelineMargins}
isSelected={isSelected}
onClick={onClick}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onClick();
}}
>
<ItemBar // using inline styles instead of props to avoid generating a css class for each item
style={{ left: `${left}%`, width: `${width}%` }}

View file

@ -0,0 +1,170 @@
/*
* 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 { EuiAccordion, EuiAccordionProps } from '@elastic/eui';
import { Location } from 'history';
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import styled from 'styled-components';
import { Margins } from '../../../../../shared/charts/Timeline';
import { WaterfallItem } from './WaterfallItem';
import {
IWaterfall,
IWaterfallItem,
} from './waterfall_helpers/waterfall_helpers';
interface AccordionWaterfallProps {
isOpen: boolean;
item: IWaterfallItem;
level: number;
serviceColors: IWaterfall['serviceColors'];
duration: IWaterfall['duration'];
waterfallItemId?: string;
location: Location;
errorsPerTransaction: IWaterfall['errorsPerTransaction'];
childrenByParentId: Record<string, IWaterfallItem[]>;
onToggleEntryTransaction?: (
nextState: EuiAccordionProps['forceState']
) => void;
timelineMargins: Margins;
onClickWaterfallItem: (item: IWaterfallItem) => void;
}
const StyledAccordion = styled(EuiAccordion).withConfig({
shouldForwardProp: (prop) =>
!['childrenCount', 'marginLeftLevel', 'hasError'].includes(prop),
})<
EuiAccordionProps & {
childrenCount: number;
marginLeftLevel: number;
hasError: boolean;
}
>`
.euiAccordion {
border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
}
.euiIEFlexWrapFix {
width: 100%;
height: 48px;
}
.euiAccordion__childWrapper {
transition: none;
}
.euiAccordion__padding--l {
padding-top: 0;
padding-bottom: 0;
}
.euiAccordion__iconWrapper {
display: flex;
position: relative;
&:after {
content: ${(props) => `'${props.childrenCount}'`};
position: absolute;
left: 20px;
top: -1px;
z-index: 1;
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
}
}
${(props) => {
const borderLeft = props.hasError
? `2px solid ${props.theme.eui.euiColorDanger};`
: `1px solid ${props.theme.eui.euiColorLightShade};`;
return `.button_${props.id} {
margin-left: ${props.marginLeftLevel}px;
border-left: ${borderLeft}
&:hover {
background-color: ${props.theme.eui.euiColorLightestShade};
}
}`;
//
}}
`;
const WaterfallItemContainer = styled.div`
position: absolute;
width: 100%;
left: 0;
`;
export function AccordionWaterfall(props: AccordionWaterfallProps) {
const [isOpen, setIsOpen] = useState(props.isOpen);
const {
item,
level,
serviceColors,
duration,
childrenByParentId,
waterfallItemId,
location,
errorsPerTransaction,
timelineMargins,
onClickWaterfallItem,
} = props;
const nextLevel = level + 1;
const errorCount =
item.docType === 'transaction'
? errorsPerTransaction[item.doc.transaction.id]
: 0;
const children = childrenByParentId[item.id] || [];
// To indent the items creating the parent/child tree
const marginLeftLevel = 8 * level;
return (
<StyledAccordion
buttonClassName={`button_${item.id}`}
key={item.id}
id={item.id}
hasError={errorCount > 0}
marginLeftLevel={marginLeftLevel}
childrenCount={children.length}
buttonContent={
<WaterfallItemContainer>
<WaterfallItem
key={item.id}
timelineMargins={timelineMargins}
color={serviceColors[item.doc.service.name]}
item={item}
totalDuration={duration}
isSelected={item.id === waterfallItemId}
errorCount={errorCount}
onClick={() => {
onClickWaterfallItem(item);
}}
/>
</WaterfallItemContainer>
}
arrowDisplay={isEmpty(children) ? 'none' : 'left'}
initialIsOpen={true}
forceState={isOpen ? 'open' : 'closed'}
onToggle={() => setIsOpen((isCurrentOpen) => !isCurrentOpen)}
>
{children.map((child) => (
<AccordionWaterfall
key={child.id}
isOpen={isOpen}
item={child}
level={nextLevel}
serviceColors={serviceColors}
waterfallItemId={waterfallItemId}
location={location}
errorsPerTransaction={errorsPerTransaction}
duration={duration}
childrenByParentId={childrenByParentId}
timelineMargins={timelineMargins}
onClickWaterfallItem={onClickWaterfallItem}
/>
))}
</StyledAccordion>
);
}

View file

@ -4,21 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiCallOut } from '@elastic/eui';
import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Location } from 'history';
import React from 'react';
import React, { useState } from 'react';
// @ts-ignore
import { StickyContainer } from 'react-sticky';
import styled from 'styled-components';
import { px } from '../../../../../../style/variables';
import { history } from '../../../../../../utils/history';
import { Timeline } from '../../../../../shared/charts/Timeline';
import { HeightRetainer } from '../../../../../shared/HeightRetainer';
import { fromQuery, toQuery } from '../../../../../shared/Links/url_helpers';
import { getAgentMarks } from '../Marks/get_agent_marks';
import { getErrorMarks } from '../Marks/get_error_marks';
import { AccordionWaterfall } from './accordion_waterfall';
import { WaterfallFlyout } from './WaterfallFlyout';
import { WaterfallItem } from './WaterfallItem';
import {
IWaterfall,
IWaterfallItem,
@ -32,7 +33,7 @@ const Container = styled.div`
const TIMELINE_MARGINS = {
top: 40,
left: 50,
left: 100,
right: 50,
bottom: 0,
};
@ -58,6 +59,7 @@ const WaterfallItemsContainer = styled.div<{
paddingTop: number;
}>`
padding-top: ${(props) => px(props.paddingTop)};
border-bottom: 1px solid ${({ theme }) => theme.eui.euiColorMediumShade};
`;
interface Props {
@ -66,72 +68,91 @@ interface Props {
location: Location;
exceedsMax: boolean;
}
export function Waterfall({
waterfall,
exceedsMax,
waterfallItemId,
location,
}: Props) {
const [isAccordionOpen, setIsAccordionOpen] = useState(true);
const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found
const waterfallHeight = itemContainerHeight * waterfall.items.length;
const { serviceColors, duration } = waterfall;
const agentMarks = getAgentMarks(waterfall.entryTransaction);
const agentMarks = getAgentMarks(waterfall.entryWaterfallTransaction?.doc);
const errorMarks = getErrorMarks(waterfall.errorItems, serviceColors);
function renderWaterfallItem(item: IWaterfallItem) {
const errorCount =
item.docType === 'transaction'
? waterfall.errorsPerTransaction[item.doc.transaction.id]
: 0;
function renderItems(
childrenByParentId: Record<string | number, IWaterfallItem[]>
) {
const { entryWaterfallTransaction } = waterfall;
if (!entryWaterfallTransaction) {
return null;
}
return (
<WaterfallItem
key={item.id}
<AccordionWaterfall
// used to recreate the entire tree when `isAccordionOpen` changes, collapsing or expanding all elements.
key={`accordion_state_${isAccordionOpen}`}
isOpen={isAccordionOpen}
item={entryWaterfallTransaction}
level={0}
serviceColors={serviceColors}
waterfallItemId={waterfallItemId}
location={location}
errorsPerTransaction={waterfall.errorsPerTransaction}
duration={duration}
childrenByParentId={childrenByParentId}
timelineMargins={TIMELINE_MARGINS}
color={serviceColors[item.doc.service.name]}
item={item}
totalDuration={duration}
isSelected={item.id === waterfallItemId}
errorCount={errorCount}
onClick={() => toggleFlyout({ item, location })}
onClickWaterfallItem={(item: IWaterfallItem) =>
toggleFlyout({ item, location })
}
/>
);
}
return (
<Container>
{exceedsMax && (
<EuiCallOut
color="warning"
size="s"
iconType="alert"
title={i18n.translate('xpack.apm.waterfall.exceedsMax', {
defaultMessage:
'Number of items in this trace exceed what is displayed',
})}
/>
)}
<StickyContainer>
<Timeline
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
height={waterfallHeight}
margins={TIMELINE_MARGINS}
/>
<WaterfallItemsContainer paddingTop={TIMELINE_MARGINS.top}>
{waterfall.items.map(renderWaterfallItem)}
</WaterfallItemsContainer>
</StickyContainer>
<HeightRetainer>
<Container>
{exceedsMax && (
<EuiCallOut
color="warning"
size="s"
iconType="alert"
title={i18n.translate('xpack.apm.waterfall.exceedsMax', {
defaultMessage:
'Number of items in this trace exceed what is displayed',
})}
/>
)}
<StickyContainer>
<div style={{ display: 'flex' }}>
<EuiButtonEmpty
style={{ zIndex: 3, position: 'absolute' }}
iconType={isAccordionOpen ? 'arrowDown' : 'arrowRight'}
onClick={() => {
setIsAccordionOpen((isOpen) => !isOpen);
}}
/>
<Timeline
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
height={waterfallHeight}
margins={TIMELINE_MARGINS}
/>
</div>
<WaterfallItemsContainer paddingTop={TIMELINE_MARGINS.top}>
{renderItems(waterfall.childrenByParentId)}
</WaterfallItemsContainer>
</StickyContainer>
<WaterfallFlyout
waterfallItemId={waterfallItemId}
waterfall={waterfall}
location={location}
toggleFlyout={toggleFlyout}
/>
</Container>
<WaterfallFlyout
waterfallItemId={waterfallItemId}
waterfall={waterfall}
location={location}
toggleFlyout={toggleFlyout}
/>
</Container>
</HeightRetainer>
);
}

View file

@ -28,7 +28,7 @@ interface IWaterfallGroup {
const ROOT_ID = 'root';
export interface IWaterfall {
entryTransaction?: Transaction;
entryWaterfallTransaction?: IWaterfallTransaction;
rootTransaction?: Transaction;
/**
@ -36,6 +36,7 @@ export interface IWaterfall {
*/
duration: number;
items: IWaterfallItem[];
childrenByParentId: Record<string | number, IWaterfallItem[]>;
errorsPerTransaction: TraceAPIResponse['errorsPerTransaction'];
errorsCount: number;
serviceColors: IServiceColors;
@ -329,6 +330,7 @@ export function getWaterfall(
errorsCount: sum(Object.values(errorsPerTransaction)),
serviceColors: {},
errorItems: [],
childrenByParentId: {},
};
}
@ -357,10 +359,8 @@ export function getWaterfall(
const duration = getWaterfallDuration(items);
const serviceColors = getServiceColors(items);
const entryTransaction = entryWaterfallTransaction?.doc;
return {
entryTransaction,
entryWaterfallTransaction,
rootTransaction,
duration,
items,
@ -368,5 +368,6 @@ export function getWaterfall(
errorsCount: errorItems.length,
serviceColors,
errorItems,
childrenByParentId: getChildrenGroupedByParentId(items),
};
}

View file

@ -8,8 +8,8 @@ import { Location } from 'history';
import React from 'react';
import { IUrlParams } from '../../../../../context/UrlParamsContext/types';
import { ServiceLegends } from './ServiceLegends';
import { Waterfall } from './Waterfall';
import { IWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers';
import { Waterfall } from './Waterfall';
interface Props {
urlParams: IUrlParams;

View file

@ -64,8 +64,8 @@ export function WaterfallWithSummmary({
});
};
const { entryTransaction } = waterfall;
if (!entryTransaction) {
const { entryWaterfallTransaction } = waterfall;
if (!entryWaterfallTransaction) {
const content = isLoading ? (
<LoadingStatePrompt />
) : (
@ -84,6 +84,8 @@ export function WaterfallWithSummmary({
return <EuiPanel paddingSize="m">{content}</EuiPanel>;
}
const entryTransaction = entryWaterfallTransaction.doc;
return (
<EuiPanel paddingSize="m">
<EuiFlexGroup>