|
@@ -1,26 +1,51 @@
|
|
|
|
+import React, {useRef, useState} from 'react';
|
|
|
|
+import ReactDOM from 'react-dom';
|
|
|
|
+import {Popper} from 'react-popper';
|
|
import {withTheme} from '@emotion/react';
|
|
import {withTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import styled from '@emotion/styled';
|
|
|
|
+import {truncate} from '@sentry/utils';
|
|
|
|
+import classNames from 'classnames';
|
|
import {EChartOption} from 'echarts';
|
|
import {EChartOption} from 'echarts';
|
|
import {Location} from 'history';
|
|
import {Location} from 'history';
|
|
|
|
+import memoize from 'lodash/memoize';
|
|
|
|
|
|
import HeatMapChart from 'app/components/charts/heatMapChart';
|
|
import HeatMapChart from 'app/components/charts/heatMapChart';
|
|
import {HeaderTitleLegend} from 'app/components/charts/styles';
|
|
import {HeaderTitleLegend} from 'app/components/charts/styles';
|
|
import TransitionChart from 'app/components/charts/transitionChart';
|
|
import TransitionChart from 'app/components/charts/transitionChart';
|
|
import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
|
|
import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
|
|
|
|
+import {Content} from 'app/components/dropdownControl';
|
|
|
|
+import DropdownMenu from 'app/components/dropdownMenu';
|
|
|
|
+import LoadingIndicator from 'app/components/loadingIndicator';
|
|
import {Panel} from 'app/components/panels';
|
|
import {Panel} from 'app/components/panels';
|
|
|
|
+import Placeholder from 'app/components/placeholder';
|
|
import QuestionTooltip from 'app/components/questionTooltip';
|
|
import QuestionTooltip from 'app/components/questionTooltip';
|
|
|
|
+import {
|
|
|
|
+ DropdownContainer,
|
|
|
|
+ DropdownItem,
|
|
|
|
+ SectionSubtext,
|
|
|
|
+} from 'app/components/quickTrace/styles';
|
|
|
|
+import Truncate from 'app/components/truncate';
|
|
import {t} from 'app/locale';
|
|
import {t} from 'app/locale';
|
|
import space from 'app/styles/space';
|
|
import space from 'app/styles/space';
|
|
import {Organization, Project} from 'app/types';
|
|
import {Organization, Project} from 'app/types';
|
|
-import {Series} from 'app/types/echarts';
|
|
|
|
-import {axisDuration, axisLabelFormatter} from 'app/utils/discover/charts';
|
|
|
|
|
|
+import {ReactEchartsRef, Series} from 'app/types/echarts';
|
|
|
|
+import {axisLabelFormatter} from 'app/utils/discover/charts';
|
|
import EventView from 'app/utils/discover/eventView';
|
|
import EventView from 'app/utils/discover/eventView';
|
|
import {TableData as TagTableData} from 'app/utils/performance/segmentExplorer/tagKeyHistogramQuery';
|
|
import {TableData as TagTableData} from 'app/utils/performance/segmentExplorer/tagKeyHistogramQuery';
|
|
|
|
+import TagTransactionsQuery from 'app/utils/performance/segmentExplorer/tagTransactionsQuery';
|
|
|
|
+import {decodeScalar} from 'app/utils/queryString';
|
|
import {Theme} from 'app/utils/theme';
|
|
import {Theme} from 'app/utils/theme';
|
|
|
|
|
|
|
|
+import {getPerformanceDuration, PerformanceDuration} from '../../utils';
|
|
|
|
+import {eventsRouteWithQuery} from '../transactionEvents/utils';
|
|
|
|
+import {generateTransactionLink} from '../utils';
|
|
|
|
+
|
|
|
|
+import {parseHistogramBucketInfo} from './utils';
|
|
|
|
+
|
|
type Props = {
|
|
type Props = {
|
|
eventView: EventView;
|
|
eventView: EventView;
|
|
location: Location;
|
|
location: Location;
|
|
|
|
+ aggregateColumn: string;
|
|
organization: Organization;
|
|
organization: Organization;
|
|
projects: Project[];
|
|
projects: Project[];
|
|
transactionName: string;
|
|
transactionName: string;
|
|
@@ -31,6 +56,34 @@ const findRowKey = row => {
|
|
return Object.keys(row).find(key => key.includes('histogram'));
|
|
return Object.keys(row).find(key => key.includes('histogram'));
|
|
};
|
|
};
|
|
|
|
|
|
|
|
+class VirtualReference {
|
|
|
|
+ boundingRect: DOMRect;
|
|
|
|
+
|
|
|
|
+ constructor(element: HTMLElement) {
|
|
|
|
+ this.boundingRect = element.getBoundingClientRect();
|
|
|
|
+ }
|
|
|
|
+ getBoundingClientRect() {
|
|
|
|
+ return this.boundingRect;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ get clientWidth() {
|
|
|
|
+ return this.getBoundingClientRect().width;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ get clientHeight() {
|
|
|
|
+ return this.getBoundingClientRect().height;
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+const getPortal = memoize((): HTMLElement => {
|
|
|
|
+ let portal = document.getElementById('heatmap-portal');
|
|
|
|
+ if (!portal) {
|
|
|
|
+ portal = document.createElement('div');
|
|
|
|
+ portal.setAttribute('id', 'heatmap-portal');
|
|
|
|
+ document.body.appendChild(portal);
|
|
|
|
+ }
|
|
|
|
+ return portal;
|
|
|
|
+});
|
|
|
|
+
|
|
const TagsHeatMap = (
|
|
const TagsHeatMap = (
|
|
props: Props & {
|
|
props: Props & {
|
|
theme: Theme;
|
|
theme: Theme;
|
|
@@ -38,7 +91,23 @@ const TagsHeatMap = (
|
|
isLoading: boolean;
|
|
isLoading: boolean;
|
|
}
|
|
}
|
|
) => {
|
|
) => {
|
|
- const {tableData, isLoading} = props;
|
|
|
|
|
|
+ const {
|
|
|
|
+ tableData,
|
|
|
|
+ isLoading,
|
|
|
|
+ organization,
|
|
|
|
+ eventView,
|
|
|
|
+ location,
|
|
|
|
+ tagKey,
|
|
|
|
+ transactionName,
|
|
|
|
+ aggregateColumn,
|
|
|
|
+ } = props;
|
|
|
|
+
|
|
|
|
+ const chartRef = useRef<ReactEchartsRef>(null);
|
|
|
|
+ const [chartElement, setChartElement] = useState<VirtualReference | undefined>();
|
|
|
|
+ const [transactionEventView, setTransactionEventView] = useState<
|
|
|
|
+ EventView | undefined
|
|
|
|
+ >();
|
|
|
|
+ const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
|
|
|
|
|
|
if (!tableData || !tableData.data || !tableData.data.length) {
|
|
if (!tableData || !tableData.data || !tableData.data.length) {
|
|
return null;
|
|
return null;
|
|
@@ -57,7 +126,8 @@ const TagsHeatMap = (
|
|
let maxCount = 0;
|
|
let maxCount = 0;
|
|
|
|
|
|
const _data = tableData.data.map(row => {
|
|
const _data = tableData.data.map(row => {
|
|
- const x = axisDuration(row[rowKey] as number);
|
|
|
|
|
|
+ const rawDuration = row[rowKey] as number;
|
|
|
|
+ const x = getPerformanceDuration(rawDuration);
|
|
const y = row.tags_value;
|
|
const y = row.tags_value;
|
|
columnNames.add(y);
|
|
columnNames.add(y);
|
|
xValues.add(x);
|
|
xValues.add(x);
|
|
@@ -86,6 +156,9 @@ const TagsHeatMap = (
|
|
splitArea: {
|
|
splitArea: {
|
|
show: true,
|
|
show: true,
|
|
},
|
|
},
|
|
|
|
+ axisLabel: {
|
|
|
|
+ formatter: (value: string) => truncate(value, 50),
|
|
|
|
+ },
|
|
} as any, // TODO(k-fish): Expand typing to allow data option
|
|
} as any, // TODO(k-fish): Expand typing to allow data option
|
|
xAxis: {
|
|
xAxis: {
|
|
boundaryGap: true,
|
|
boundaryGap: true,
|
|
@@ -151,6 +224,24 @@ const TagsHeatMap = (
|
|
const reloading = isLoading;
|
|
const reloading = isLoading;
|
|
const loading = isLoading;
|
|
const loading = isLoading;
|
|
|
|
|
|
|
|
+ const onOpenMenu = () => {
|
|
|
|
+ setIsMenuOpen(true);
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const onCloseMenu = () => {
|
|
|
|
+ setIsMenuOpen(false);
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const shouldIgnoreMenuClose = e => {
|
|
|
|
+ if (chartRef.current?.getEchartsInstance().getDom().contains(e.target)) {
|
|
|
|
+ // Ignore the menu being closed if the echart is being clicked.
|
|
|
|
+ return true;
|
|
|
|
+ }
|
|
|
|
+ return false;
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ const histogramBucketInfo = parseHistogramBucketInfo(tableData.data[0]);
|
|
|
|
+
|
|
return (
|
|
return (
|
|
<StyledPanel>
|
|
<StyledPanel>
|
|
<StyledHeaderTitleLegend>
|
|
<StyledHeaderTitleLegend>
|
|
@@ -166,13 +257,208 @@ const TagsHeatMap = (
|
|
|
|
|
|
<TransitionChart loading={loading} reloading={reloading}>
|
|
<TransitionChart loading={loading} reloading={reloading}>
|
|
<TransparentLoadingMask visible={reloading} />
|
|
<TransparentLoadingMask visible={reloading} />
|
|
|
|
+ <DropdownMenu
|
|
|
|
+ onOpen={onOpenMenu}
|
|
|
|
+ onClose={onCloseMenu}
|
|
|
|
+ shouldIgnoreClickOutside={shouldIgnoreMenuClose}
|
|
|
|
+ >
|
|
|
|
+ {({isOpen, getMenuProps, actions}) => {
|
|
|
|
+ const onChartClick = bucket => {
|
|
|
|
+ const htmlEvent = bucket.event.event;
|
|
|
|
+ // Make a copy of the dims because echarts can remove elements after this click happens.
|
|
|
|
+ // TODO(k-fish): Look at improving this to respond properly to resize events.
|
|
|
|
+ const virtualRef = new VirtualReference(htmlEvent.target);
|
|
|
|
+ setChartElement(virtualRef);
|
|
|
|
+
|
|
|
|
+ const newTransactionEventView = eventView.clone();
|
|
|
|
+
|
|
|
|
+ newTransactionEventView.fields = [{field: aggregateColumn}];
|
|
|
|
+ const [_, tagValue] = bucket.value;
|
|
|
|
+
|
|
|
|
+ if (histogramBucketInfo) {
|
|
|
|
+ const row = tableData.data[bucket.dataIndex];
|
|
|
|
+ const currentBucketStart = parseInt(
|
|
|
|
+ `${row[histogramBucketInfo.histogramField]}`,
|
|
|
|
+ 10
|
|
|
|
+ );
|
|
|
|
+ const currentBucketEnd =
|
|
|
|
+ currentBucketStart + histogramBucketInfo.bucketSize;
|
|
|
|
+
|
|
|
|
+ newTransactionEventView.additionalConditions.setTagValues(
|
|
|
|
+ aggregateColumn,
|
|
|
|
+ [`>=${currentBucketStart}`, `<${currentBucketEnd}`]
|
|
|
|
+ );
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ newTransactionEventView.additionalConditions.setTagValues(tagKey, [
|
|
|
|
+ tagValue,
|
|
|
|
+ ]);
|
|
|
|
|
|
- <HeatMapChart visualMaps={visualMaps} series={series} {...chartOptions} />
|
|
|
|
|
|
+ setTransactionEventView(newTransactionEventView);
|
|
|
|
+
|
|
|
|
+ if (!isMenuOpen) {
|
|
|
|
+ actions.open();
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ return (
|
|
|
|
+ <React.Fragment>
|
|
|
|
+ {ReactDOM.createPortal(
|
|
|
|
+ <div>
|
|
|
|
+ {chartElement ? (
|
|
|
|
+ <Popper referenceElement={chartElement} placement="bottom">
|
|
|
|
+ {({ref, style, placement}) => (
|
|
|
|
+ <StyledDropdownContainer
|
|
|
|
+ ref={ref}
|
|
|
|
+ style={style}
|
|
|
|
+ className="anchor-middle"
|
|
|
|
+ data-placement={placement}
|
|
|
|
+ >
|
|
|
|
+ <StyledDropdownContent
|
|
|
|
+ {...getMenuProps({
|
|
|
|
+ className: classNames('dropdown-menu'),
|
|
|
|
+ })}
|
|
|
|
+ isOpen={isOpen}
|
|
|
|
+ alignMenu="right"
|
|
|
|
+ blendCorner={false}
|
|
|
|
+ >
|
|
|
|
+ {transactionEventView ? (
|
|
|
|
+ <TagTransactionsQuery
|
|
|
|
+ query={transactionEventView.getQueryWithAdditionalConditions()}
|
|
|
|
+ location={location}
|
|
|
|
+ eventView={transactionEventView}
|
|
|
|
+ orgSlug={organization.slug}
|
|
|
|
+ limit={4}
|
|
|
|
+ referrer="api.performance.tag-page"
|
|
|
|
+ >
|
|
|
|
+ {({
|
|
|
|
+ isLoading: isTransactionsLoading,
|
|
|
|
+ tableData: transactionTableData,
|
|
|
|
+ }) => {
|
|
|
|
+ const moreEventsTarget = isTransactionsLoading
|
|
|
|
+ ? null
|
|
|
|
+ : eventsRouteWithQuery({
|
|
|
|
+ orgSlug: organization.slug,
|
|
|
|
+ transaction: transactionName,
|
|
|
|
+ projectID: decodeScalar(location.query.project),
|
|
|
|
+ query: {
|
|
|
|
+ ...transactionEventView.generateQueryStringObject(),
|
|
|
|
+ query:
|
|
|
|
+ transactionEventView.getQueryWithAdditionalConditions(),
|
|
|
|
+ },
|
|
|
|
+ });
|
|
|
|
+ return (
|
|
|
|
+ <React.Fragment>
|
|
|
|
+ {isTransactionsLoading ? (
|
|
|
|
+ <LoadingContainer>
|
|
|
|
+ <LoadingIndicator size={40} hideMessage />
|
|
|
|
+ </LoadingContainer>
|
|
|
|
+ ) : (
|
|
|
|
+ <div>
|
|
|
|
+ {!transactionTableData.data.length ? (
|
|
|
|
+ <Placeholder />
|
|
|
|
+ ) : null}
|
|
|
|
+ {[...transactionTableData.data]
|
|
|
|
+ .slice(0, 3)
|
|
|
|
+ .map(row => {
|
|
|
|
+ const target = generateTransactionLink(
|
|
|
|
+ transactionName
|
|
|
|
+ )(organization, row, location.query);
|
|
|
|
+
|
|
|
|
+ return (
|
|
|
|
+ <DropdownItem
|
|
|
|
+ width="small"
|
|
|
|
+ key={row.id}
|
|
|
|
+ to={target}
|
|
|
|
+ >
|
|
|
|
+ <DropdownItemContainer>
|
|
|
|
+ <Truncate
|
|
|
|
+ value={row.id}
|
|
|
|
+ maxLength={12}
|
|
|
|
+ />
|
|
|
|
+ <SectionSubtext>
|
|
|
|
+ <PerformanceDuration
|
|
|
|
+ milliseconds={
|
|
|
|
+ row[aggregateColumn]
|
|
|
|
+ }
|
|
|
|
+ abbreviation
|
|
|
|
+ />
|
|
|
|
+ </SectionSubtext>
|
|
|
|
+ </DropdownItemContainer>
|
|
|
|
+ </DropdownItem>
|
|
|
|
+ );
|
|
|
|
+ })}
|
|
|
|
+ {moreEventsTarget &&
|
|
|
|
+ transactionTableData.data.length > 3 ? (
|
|
|
|
+ <DropdownItem
|
|
|
|
+ width="small"
|
|
|
|
+ to={moreEventsTarget}
|
|
|
|
+ >
|
|
|
|
+ <DropdownItemContainer>
|
|
|
|
+ {t('View all events')}
|
|
|
|
+ </DropdownItemContainer>
|
|
|
|
+ </DropdownItem>
|
|
|
|
+ ) : null}
|
|
|
|
+ </div>
|
|
|
|
+ )}
|
|
|
|
+ </React.Fragment>
|
|
|
|
+ );
|
|
|
|
+ }}
|
|
|
|
+ </TagTransactionsQuery>
|
|
|
|
+ ) : null}
|
|
|
|
+ </StyledDropdownContent>
|
|
|
|
+ </StyledDropdownContainer>
|
|
|
|
+ )}
|
|
|
|
+ </Popper>
|
|
|
|
+ ) : null}
|
|
|
|
+ </div>,
|
|
|
|
+ getPortal()
|
|
|
|
+ )}
|
|
|
|
+
|
|
|
|
+ <HeatMapChart
|
|
|
|
+ ref={chartRef}
|
|
|
|
+ visualMaps={visualMaps}
|
|
|
|
+ series={series}
|
|
|
|
+ onClick={onChartClick}
|
|
|
|
+ {...chartOptions}
|
|
|
|
+ />
|
|
|
|
+ </React.Fragment>
|
|
|
|
+ );
|
|
|
|
+ }}
|
|
|
|
+ </DropdownMenu>
|
|
</TransitionChart>
|
|
</TransitionChart>
|
|
</StyledPanel>
|
|
</StyledPanel>
|
|
);
|
|
);
|
|
};
|
|
};
|
|
|
|
|
|
|
|
+const LoadingContainer = styled('div')`
|
|
|
|
+ width: 200px;
|
|
|
|
+ height: 100px;
|
|
|
|
+
|
|
|
|
+ display: flex;
|
|
|
|
+ align-items: center;
|
|
|
|
+ justify-content: center;
|
|
|
|
+`;
|
|
|
|
+
|
|
|
|
+const DropdownItemContainer = styled('div')`
|
|
|
|
+ width: 100%;
|
|
|
|
+ display: flex;
|
|
|
|
+ flex-direction: row;
|
|
|
|
+
|
|
|
|
+ justify-content: space-between;
|
|
|
|
+`;
|
|
|
|
+
|
|
|
|
+const StyledDropdownContainer = styled(DropdownContainer)`
|
|
|
|
+ z-index: ${p => p.theme.zIndex.dropdown};
|
|
|
|
+`;
|
|
|
|
+
|
|
|
|
+const StyledDropdownContent = styled(Content)`
|
|
|
|
+ right: auto;
|
|
|
|
+ transform: translate(-50%);
|
|
|
|
+
|
|
|
|
+ overflow: visible;
|
|
|
|
+`;
|
|
|
|
+
|
|
const StyledPanel = styled(Panel)`
|
|
const StyledPanel = styled(Panel)`
|
|
padding: ${space(3)};
|
|
padding: ${space(3)};
|
|
margin-bottom: 0;
|
|
margin-bottom: 0;
|