Waterfall layout and expansion fixes (#114889)

Fix according toggling behavior on trace waterfall. Previously clicking on any item would collapse the whole waterfall, now it just collapses the one you clicked on.

Truncate spans so ones with very long names don't overflow.

Make the left margin relative to the max level of depth so waterfalls with deep trees don't overflow.
This commit is contained in:
Nathan L Smith 2021-10-19 16:04:02 -05:00 committed by GitHub
parent 26d42523c5
commit db53a79cc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 6019 additions and 2344 deletions

View file

@ -7,7 +7,7 @@
import { EuiAccordion, EuiAccordionProps } from '@elastic/eui';
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import React, { Dispatch, SetStateAction, useState } from 'react';
import { euiStyled } from '../../../../../../../../../../src/plugins/kibana_react/common';
import { Margins } from '../../../../../shared/charts/Timeline';
import { WaterfallItem } from './waterfall_item';
@ -22,8 +22,8 @@ interface AccordionWaterfallProps {
level: number;
duration: IWaterfall['duration'];
waterfallItemId?: string;
setMaxLevel: Dispatch<SetStateAction<number>>;
waterfall: IWaterfall;
onToggleEntryTransaction?: () => void;
timelineMargins: Margins;
onClickWaterfallItem: (item: IWaterfallSpanOrTransaction) => void;
}
@ -97,12 +97,13 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) {
duration,
waterfall,
waterfallItemId,
setMaxLevel,
timelineMargins,
onClickWaterfallItem,
onToggleEntryTransaction,
} = props;
const nextLevel = level + 1;
setMaxLevel(nextLevel);
const children = waterfall.childrenByParentId[item.id] || [];
const errorCount = waterfall.getErrorCount(item.id);
@ -139,9 +140,6 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) {
forceState={isOpen ? 'open' : 'closed'}
onToggle={() => {
setIsOpen((isCurrentOpen) => !isCurrentOpen);
if (onToggleEntryTransaction) {
onToggleEntryTransaction();
}
}}
>
{children.map((child) => (

View file

@ -29,13 +29,6 @@ const Container = euiStyled.div`
overflow: hidden;
`;
const TIMELINE_MARGINS = {
top: 40,
left: 100,
right: 50,
bottom: 0,
};
const toggleFlyout = ({
history,
item,
@ -72,6 +65,16 @@ export function Waterfall({ waterfall, waterfallItemId }: Props) {
const agentMarks = getAgentMarks(waterfall.entryWaterfallTransaction?.doc);
const errorMarks = getErrorMarks(waterfall.errorItems);
// Calculate the left margin relative to the deepest level, or 100px, whichever
// is more.
const [maxLevel, setMaxLevel] = useState(0);
const timelineMargins = {
top: 40,
left: Math.max(100, maxLevel * 10),
right: 50,
bottom: 0,
};
return (
<HeightRetainer>
<Container>
@ -99,7 +102,7 @@ export function Waterfall({ waterfall, waterfallItemId }: Props) {
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
height={waterfallHeight}
margins={TIMELINE_MARGINS}
margins={timelineMargins}
/>
</div>
<WaterfallItemsContainer>
@ -110,16 +113,14 @@ export function Waterfall({ waterfall, waterfallItemId }: Props) {
isOpen={isAccordionOpen}
item={waterfall.entryWaterfallTransaction}
level={0}
setMaxLevel={setMaxLevel}
waterfallItemId={waterfallItemId}
duration={duration}
waterfall={waterfall}
timelineMargins={TIMELINE_MARGINS}
timelineMargins={timelineMargins}
onClickWaterfallItem={(item: IWaterfallItem) =>
toggleFlyout({ history, item })
}
onToggleEntryTransaction={() =>
setIsAccordionOpen((isOpen) => !isOpen)
}
/>
)}
</WaterfallItemsContainer>

View file

@ -17,6 +17,7 @@ import {
} from '../../../../../../../common/elasticsearch_fieldnames';
import { asDuration } from '../../../../../../../common/utils/formatters';
import { Margins } from '../../../../../shared/charts/Timeline';
import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip';
import { SyncBadge } from './sync_badge';
import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
import { FailureBadge } from './failure_badge';
@ -67,6 +68,7 @@ const ItemText = euiStyled.span`
display: flex;
align-items: center;
height: ${({ theme }) => theme.eui.euiSizeL};
max-width: 100%;
/* add margin to all direct descendants */
& > * {
@ -160,7 +162,11 @@ function NameLabel({ item }: { item: IWaterfallSpanOrTransaction }) {
: '';
name = `${item.doc.span.composite.count}${compositePrefix} ${name}`;
}
return <EuiText size="s">{name}</EuiText>;
return (
<EuiText style={{ overflow: 'hidden' }} size="s">
<TruncateWithTooltip content={name} text={name} />
</EuiText>
);
case 'transaction':
return (
<EuiTitle size="xxs">

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ComponentType } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
import { WaterfallContainer } from './index';
import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers';
import {
inferredSpans,
simpleTrace,
traceChildStartBeforeParent,
traceWithErrors,
urlParams,
} from './waterfallContainer.stories.data';
export default {
title: 'app/TransactionDetails/Waterfall',
component: WaterfallContainer,
decorators: [
(Story: ComponentType) => (
<MemoryRouter>
<MockApmPluginContextWrapper>
<Story />
</MockApmPluginContextWrapper>
</MemoryRouter>
),
],
};
export function Example() {
const waterfall = getWaterfall(simpleTrace, '975c8d5bfd1dd20b');
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
}
export function WithErrors() {
const waterfall = getWaterfall(traceWithErrors, '975c8d5bfd1dd20b');
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
}
export function ChildStartsBeforeParent() {
const waterfall = getWaterfall(
traceChildStartBeforeParent,
'975c8d5bfd1dd20b'
);
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
}
export function InferredSpans() {
const waterfall = getWaterfall(inferredSpans, 'f2387d37260d00bd');
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Meta, Story } from '@storybook/react';
import React, { ComponentProps } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context';
import { WaterfallContainer } from './index';
import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers';
import {
inferredSpans,
manyChildrenWithSameLength,
simpleTrace,
traceChildStartBeforeParent,
traceWithErrors,
urlParams as testUrlParams,
} from './waterfall_container.stories.data';
type Args = ComponentProps<typeof WaterfallContainer>;
const stories: Meta<Args> = {
title: 'app/TransactionDetails/Waterfall',
component: WaterfallContainer,
decorators: [
(StoryComponent) => (
<MemoryRouter
initialEntries={[
'/services/{serviceName}/transactions/view?rangeFrom=now-15m&rangeTo=now&transactionName=testTransactionName',
]}
>
<MockApmPluginContextWrapper>
<StoryComponent />
</MockApmPluginContextWrapper>
</MemoryRouter>
),
],
};
export default stories;
export const Example: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
};
Example.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(simpleTrace, '975c8d5bfd1dd20b'),
};
export const WithErrors: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
};
WithErrors.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(traceWithErrors, '975c8d5bfd1dd20b'),
};
export const ChildStartsBeforeParent: Story<Args> = ({
urlParams,
waterfall,
}) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
};
ChildStartsBeforeParent.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(traceChildStartBeforeParent, '975c8d5bfd1dd20b'),
};
export const InferredSpans: Story<Args> = ({ urlParams, waterfall }) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
};
InferredSpans.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(inferredSpans, 'f2387d37260d00bd'),
};
export const ManyChildrenWithSameLength: Story<Args> = ({
urlParams,
waterfall,
}) => {
return <WaterfallContainer urlParams={urlParams} waterfall={waterfall} />;
};
ManyChildrenWithSameLength.args = {
urlParams: testUrlParams,
waterfall: getWaterfall(manyChildrenWithSameLength, '9a7f717439921d39'),
};

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { composeStories } from '@storybook/testing-react';
import { render } from '@testing-library/react';
import React from 'react';
import { disableConsoleWarning } from '../../../../../utils/testHelpers';
import * as stories from './waterfall_container.stories';
const { Example } = composeStories(stories);
describe('WaterfallContainer', () => {
let consoleMock: jest.SpyInstance;
beforeAll(() => {
consoleMock = disableConsoleWarning('Warning: componentWillReceiveProps');
});
afterAll(() => {
consoleMock.mockRestore();
});
it('renders', () => {
expect(() => render(<Example />)).not.toThrowError();
});
it('expands and contracts the accordion', () => {
const { getAllByRole } = render(<Example />);
const buttons = getAllByRole('button');
const parentItem = buttons[2];
const childItem = buttons[3];
parentItem.click();
expect(parentItem).toHaveAttribute('aria-expanded', 'false');
expect(childItem).toHaveAttribute('aria-expanded', 'true');
});
});