Files
2024-02-15 17:01:50 +00:00

655 lines
19 KiB
TypeScript

import {Fragment} from 'react';
import {browserHistory} from 'react-router';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import type {Location, LocationDescriptorObject} from 'history';
import {openModal} from 'sentry/actionCreators/modal';
import GridEditable, {
COL_WIDTH_MINIMUM,
COL_WIDTH_UNDEFINED,
} from 'sentry/components/gridEditable';
import SortLink from 'sentry/components/gridEditable/sortLink';
import Link from 'sentry/components/links/link';
import {Tooltip} from 'sentry/components/tooltip';
import Truncate from 'sentry/components/truncate';
import {IconStack} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {Organization} from 'sentry/types';
import {trackAnalytics} from 'sentry/utils/analytics';
import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
import type {TableData, TableDataRow} from 'sentry/utils/discover/discoverQuery';
import type EventView from 'sentry/utils/discover/eventView';
import {
isFieldSortable,
pickRelevantLocationQueryStrings,
} from 'sentry/utils/discover/eventView';
import {
DURATION_UNITS,
getFieldRenderer,
SIZE_UNITS,
} from 'sentry/utils/discover/fieldRenderers';
import type {Column} from 'sentry/utils/discover/fields';
import {
fieldAlignment,
getEquationAliasIndex,
isEquationAlias,
} from 'sentry/utils/discover/fields';
import {DisplayModes, TOP_N} from 'sentry/utils/discover/types';
import {
eventDetailsRouteWithEventView,
generateEventSlug,
} from 'sentry/utils/discover/urls';
import ViewReplayLink from 'sentry/utils/discover/viewReplayLink';
import {getShortEventId} from 'sentry/utils/events';
import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
import {decodeList} from 'sentry/utils/queryString';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import useProjects from 'sentry/utils/useProjects';
import {useRoutes} from 'sentry/utils/useRoutes';
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
import {getTraceDetailsUrl} from 'sentry/views/performance/traceDetails/utils';
import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
import {
getExpandedResults,
getTargetForTransactionSummaryLink,
pushEventViewToLocation,
} from '../utils';
import {QuickContextHoverWrapper} from './quickContext/quickContextWrapper';
import {ContextType} from './quickContext/utils';
import CellAction, {Actions, updateQuery} from './cellAction';
import ColumnEditModal, {modalCss} from './columnEditModal';
import TableActions from './tableActions';
import TopResultsIndicator from './topResultsIndicator';
import type {TableColumn} from './types';
export type TableViewProps = {
error: string | null;
eventView: EventView;
isFirstPage: boolean;
isLoading: boolean;
location: Location;
measurementKeys: null | string[];
onChangeShowTags: () => void;
organization: Organization;
showTags: boolean;
tableData: TableData | null | undefined;
title: string;
customMeasurements?: CustomMeasurementCollection;
isHomepage?: boolean;
spanOperationBreakdownKeys?: string[];
};
/**
* The `TableView` is marked with leading _ in its method names. It consumes
* the EventView object given in its props to generate new EventView objects
* for actions like resizing column.
*
* The entire state of the table view (or event view) is co-located within
* the EventView object. This object is fed from the props.
*
* Attempting to modify the state, and therefore, modifying the given EventView
* object given from its props, will generate new instances of EventView objects.
*
* In most cases, the new EventView object differs from the previous EventView
* object. The new EventView object is pushed to the location object.
*/
function TableView(props: TableViewProps) {
const {projects} = useProjects();
const routes = useRoutes();
const replayLinkGenerator = generateReplayLink(routes);
/**
* Updates a column on resizing
*/
function _resizeColumn(
columnIndex: number,
nextColumn: TableColumn<keyof TableDataRow>
) {
const {location, eventView} = props;
const newWidth = nextColumn.width ? Number(nextColumn.width) : COL_WIDTH_UNDEFINED;
const nextEventView = eventView.withResizedColumn(columnIndex, newWidth);
pushEventViewToLocation({
location,
nextEventView,
extraQuery: pickRelevantLocationQueryStrings(location),
});
}
function _renderPrependColumns(
isHeader: boolean,
dataRow?: any,
rowIndex?: number
): React.ReactNode[] {
const {organization, eventView, tableData, location, isHomepage} = props;
const hasAggregates = eventView.hasAggregateField();
const hasIdField = eventView.hasIdField();
if (isHeader) {
if (hasAggregates) {
return [
<PrependHeader key="header-icon">
<IconStack size="sm" />
</PrependHeader>,
];
}
if (!hasIdField) {
return [
<PrependHeader key="header-event-id">
<SortLink
align="left"
title={t('event id')}
direction={undefined}
canSort={false}
generateSortLink={() => undefined}
/>
</PrependHeader>,
];
}
return [];
}
if (hasAggregates) {
const nextView = getExpandedResults(eventView, {}, dataRow);
const target = {
pathname: location.pathname,
query: nextView.generateQueryStringObject(),
};
return [
<Tooltip key={`eventlink${rowIndex}`} title={t('Open Group')}>
<Link
to={target}
data-test-id="open-group"
onClick={() => {
if (nextView.isEqualTo(eventView)) {
Sentry.captureException(new Error('Failed to drilldown'));
}
}}
>
<StyledIcon size="sm" />
</Link>
</Tooltip>,
];
}
if (!hasIdField) {
let value = dataRow.id;
if (tableData?.meta) {
const fieldRenderer = getFieldRenderer('id', tableData.meta);
value = fieldRenderer(dataRow, {organization, location});
}
const eventSlug = generateEventSlug(dataRow);
const target = eventDetailsRouteWithEventView({
orgSlug: organization.slug,
eventSlug,
eventView,
isHomepage,
});
const eventIdLink = (
<StyledLink data-test-id="view-event" to={target}>
{value}
</StyledLink>
);
return [
<QuickContextHoverWrapper
key={`quickContextEventHover${rowIndex}`}
dataRow={dataRow}
contextType={ContextType.EVENT}
organization={organization}
projects={projects}
eventView={eventView}
>
{eventIdLink}
</QuickContextHoverWrapper>,
];
}
return [];
}
function _renderGridHeaderCell(
column: TableColumn<keyof TableDataRow>
): React.ReactNode {
const {eventView, location, tableData} = props;
const tableMeta = tableData?.meta;
const align = fieldAlignment(column.name, column.type, tableMeta);
const field = {field: column.key as string, width: column.width};
function generateSortLink(): LocationDescriptorObject | undefined {
if (!tableMeta) {
return undefined;
}
const nextEventView = eventView.sortOnField(field, tableMeta);
const queryStringObject = nextEventView.generateQueryStringObject();
// Need to pull yAxis from location since eventView only stores 1 yAxis field at time
queryStringObject.yAxis = decodeList(location.query.yAxis);
return {
...location,
query: queryStringObject,
};
}
const currentSort = eventView.sortForField(field, tableMeta);
const canSort = isFieldSortable(field, tableMeta);
let titleText = isEquationAlias(column.name)
? eventView.getEquations()[getEquationAliasIndex(column.name)]
: column.name;
if (column.name.toLowerCase() === 'replayid') {
titleText = 'Replay';
}
const title = (
<StyledTooltip title={titleText}>
<Truncate value={titleText} maxLength={60} expandable={false} />
</StyledTooltip>
);
return (
<SortLink
align={align}
title={title}
direction={currentSort ? currentSort.kind : undefined}
canSort={canSort}
generateSortLink={generateSortLink}
/>
);
}
function _renderGridBodyCell(
column: TableColumn<keyof TableDataRow>,
dataRow: TableDataRow,
rowIndex: number,
columnIndex: number
): React.ReactNode {
const {isFirstPage, eventView, location, organization, tableData, isHomepage} = props;
if (!tableData || !tableData.meta) {
return dataRow[column.key];
}
const columnKey = String(column.key);
const fieldRenderer = getFieldRenderer(columnKey, tableData.meta, false);
const display = eventView.getDisplayMode();
const isTopEvents =
display === DisplayModes.TOP5 || display === DisplayModes.DAILYTOP5;
const topEvents = eventView.topEvents ? parseInt(eventView.topEvents, 10) : TOP_N;
const count = Math.min(tableData?.data?.length ?? topEvents, topEvents);
const unit = tableData.meta.units?.[columnKey];
let cell = fieldRenderer(dataRow, {organization, location, unit});
if (columnKey === 'id') {
const eventSlug = generateEventSlug(dataRow);
const target = eventDetailsRouteWithEventView({
orgSlug: organization.slug,
eventSlug,
eventView,
isHomepage,
});
const idLink = (
<StyledLink data-test-id="view-event" to={target}>
{cell}
</StyledLink>
);
cell = (
<QuickContextHoverWrapper
organization={organization}
dataRow={dataRow}
contextType={ContextType.EVENT}
projects={projects}
eventView={eventView}
>
{idLink}
</QuickContextHoverWrapper>
);
} else if (columnKey === 'transaction' && dataRow.transaction) {
cell = (
<TransactionLink
data-test-id="tableView-transaction-link"
to={getTargetForTransactionSummaryLink(
dataRow,
organization,
projects,
eventView,
location
)}
>
{cell}
</TransactionLink>
);
} else if (columnKey === 'trace') {
const dateSelection = eventView.normalizeDateSelection(location);
if (dataRow.trace) {
const target = getTraceDetailsUrl(
organization,
String(dataRow.trace),
dateSelection,
{}
);
cell = (
<Tooltip title={t('View Trace')}>
<StyledLink data-test-id="view-trace" to={target}>
{cell}
</StyledLink>
</Tooltip>
);
}
} else if (columnKey === 'replayId') {
if (dataRow.replayId) {
if (!dataRow['project.name']) {
return getShortEventId(String(dataRow.replayId));
}
const target = replayLinkGenerator(organization, dataRow, undefined);
cell = (
<ViewReplayLink replayId={dataRow.replayId} to={target}>
{cell}
</ViewReplayLink>
);
}
} else if (columnKey === 'profile.id') {
const projectSlug = dataRow.project || dataRow['project.name'];
const profileId = dataRow['profile.id'];
if (projectSlug && profileId) {
const target = generateProfileFlamechartRoute({
orgSlug: organization.slug,
projectSlug: String(projectSlug),
profileId: String(profileId),
});
cell = (
<StyledTooltip title={t('View Profile')}>
<StyledLink
data-test-id="view-profile"
to={target}
onClick={() =>
trackAnalytics('profiling_views.go_to_flamegraph', {
organization,
source: 'discover.table',
})
}
>
{cell}
</StyledLink>
</StyledTooltip>
);
}
}
const topResultsIndicator =
isFirstPage && isTopEvents && rowIndex < topEvents && columnIndex === 0 ? (
// Add one if we need to include Other in the series
<TopResultsIndicator count={count} index={rowIndex} />
) : null;
const fieldName = columnKey;
const value = dataRow[fieldName];
if (
tableData.meta[fieldName] === 'integer' &&
typeof value === 'number' &&
value > 999
) {
return (
<Tooltip
title={value.toLocaleString()}
containerDisplayMode="block"
position="right"
>
{topResultsIndicator}
<CellAction
column={column}
dataRow={dataRow}
handleCellAction={handleCellAction(dataRow, column)}
>
{cell}
</CellAction>
</Tooltip>
);
}
return (
<Fragment>
{topResultsIndicator}
<CellAction
column={column}
dataRow={dataRow}
handleCellAction={handleCellAction(dataRow, column)}
>
{cell}
</CellAction>
</Fragment>
);
}
function handleEditColumns() {
const {
organization,
eventView,
measurementKeys,
spanOperationBreakdownKeys,
customMeasurements,
} = props;
openModal(
modalProps => (
<ColumnEditModal
{...modalProps}
organization={organization}
measurementKeys={measurementKeys}
spanOperationBreakdownKeys={spanOperationBreakdownKeys}
columns={eventView.getColumns().map(col => col.column)}
onApply={handleUpdateColumns}
customMeasurements={customMeasurements}
/>
),
{modalCss, closeEvents: 'escape-key'}
);
}
function handleCellAction(
dataRow: TableDataRow,
column: TableColumn<keyof TableDataRow>
) {
return (action: Actions, value: React.ReactText) => {
const {eventView, organization, location, tableData, isHomepage} = props;
const query = new MutableSearch(eventView.query);
let nextView = eventView.clone();
trackAnalytics('discover_v2.results.cellaction', {
organization,
action,
});
switch (action) {
case Actions.RELEASE: {
const maybeProject = projects.find(project => {
return project.slug === dataRow.project;
});
browserHistory.push(
normalizeUrl({
pathname: `/organizations/${
organization.slug
}/releases/${encodeURIComponent(value)}/`,
query: {
...nextView.getPageFiltersQuery(),
project: maybeProject ? maybeProject.id : undefined,
},
})
);
return;
}
case Actions.DRILLDOWN: {
// count_unique(column) drilldown
trackAnalytics('discover_v2.results.drilldown', {
organization,
});
// Drilldown into each distinct value and get a count() for each value.
nextView = getExpandedResults(nextView, {}, dataRow).withNewColumn({
kind: 'function',
function: ['count', '', undefined, undefined],
});
browserHistory.push(
normalizeUrl(nextView.getResultsViewUrlTarget(organization.slug, isHomepage))
);
return;
}
default: {
// Some custom perf metrics have units.
// These custom perf metrics need to be adjusted to the correct value.
let cellValue = value;
const unit = tableData?.meta?.units?.[column.name];
if (typeof cellValue === 'number' && unit) {
if (Object.keys(SIZE_UNITS).includes(unit)) {
cellValue *= SIZE_UNITS[unit];
} else if (Object.keys(DURATION_UNITS).includes(unit)) {
cellValue *= DURATION_UNITS[unit];
}
}
updateQuery(query, action, column, cellValue);
}
}
nextView.query = query.formatString();
const target = nextView.getResultsViewUrlTarget(organization.slug, isHomepage);
// Get yAxis from location
target.query.yAxis = decodeList(location.query.yAxis);
browserHistory.push(normalizeUrl(target));
};
}
function handleUpdateColumns(columns: Column[]): void {
const {organization, eventView, location, isHomepage} = props;
// metrics
trackAnalytics('discover_v2.update_columns', {
organization,
});
const nextView = eventView.withColumns(columns);
const resultsViewUrlTarget = nextView.getResultsViewUrlTarget(
organization.slug,
isHomepage
);
// Need to pull yAxis from location since eventView only stores 1 yAxis field at time
const previousYAxis = decodeList(location.query.yAxis);
resultsViewUrlTarget.query.yAxis = previousYAxis.filter(yAxis =>
nextView.getYAxisOptions().find(({value}) => value === yAxis)
);
browserHistory.push(normalizeUrl(resultsViewUrlTarget));
}
function renderHeaderButtons() {
const {
organization,
title,
eventView,
isLoading,
error,
tableData,
location,
onChangeShowTags,
showTags,
} = props;
return (
<TableActions
title={title}
isLoading={isLoading}
error={error}
organization={organization}
eventView={eventView}
onEdit={handleEditColumns}
tableData={tableData}
location={location}
onChangeShowTags={onChangeShowTags}
showTags={showTags}
supportsInvestigationRule
/>
);
}
const {error, eventView, isLoading, location, tableData} = props;
const columnOrder = eventView.getColumns();
const columnSortBy = eventView.getSorts();
const prependColumnWidths = eventView.hasAggregateField()
? ['40px']
: eventView.hasIdField()
? []
: [`minmax(${COL_WIDTH_MINIMUM}px, max-content)`];
return (
<GridEditable
isLoading={isLoading}
error={error}
data={tableData ? tableData.data : []}
columnOrder={columnOrder}
columnSortBy={columnSortBy}
title={t('Results')}
grid={{
renderHeadCell: _renderGridHeaderCell as any,
renderBodyCell: _renderGridBodyCell as any,
onResizeColumn: _resizeColumn as any,
renderPrependColumns: _renderPrependColumns as any,
prependColumnWidths,
}}
headerButtons={renderHeaderButtons}
location={location}
/>
);
}
const PrependHeader = styled('span')`
color: ${p => p.theme.subText};
`;
const StyledTooltip = styled(Tooltip)`
display: initial;
max-width: max-content;
`;
export const StyledLink = styled(Link)`
& div {
display: inline;
}
`;
export const TransactionLink = styled(Link)`
${p => p.theme.overflowEllipsis}
`;
const StyledIcon = styled(IconStack)`
vertical-align: middle;
`;
export default TableView;