mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
449 lines
13 KiB
TypeScript
449 lines
13 KiB
TypeScript
import {Component, Fragment} from 'react';
|
|
import {browserHistory} from 'react-router';
|
|
import styled from '@emotion/styled';
|
|
import type {Location, LocationDescriptorObject} from 'history';
|
|
|
|
import type {GridColumn} from 'sentry/components/gridEditable';
|
|
import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
|
|
import SortLink from 'sentry/components/gridEditable/sortLink';
|
|
import Link from 'sentry/components/links/link';
|
|
import Pagination from 'sentry/components/pagination';
|
|
import Tag from 'sentry/components/tag';
|
|
import {IconStar} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import type {Organization, Project} from 'sentry/types';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import type {EventsMetaType} from 'sentry/utils/discover/eventView';
|
|
import type EventView from 'sentry/utils/discover/eventView';
|
|
import {isFieldSortable} from 'sentry/utils/discover/eventView';
|
|
import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
|
|
import type {Sort} from 'sentry/utils/discover/fields';
|
|
import {fieldAlignment, getAggregateAlias} from 'sentry/utils/discover/fields';
|
|
import type {WebVital} from 'sentry/utils/fields';
|
|
import type {
|
|
TableData,
|
|
TableDataRow,
|
|
} from 'sentry/utils/performance/vitals/vitalsDetailsTableQuery';
|
|
import VitalsDetailsTableQuery from 'sentry/utils/performance/vitals/vitalsDetailsTableQuery';
|
|
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
|
|
import CellAction, {Actions, updateQuery} from 'sentry/views/discover/table/cellAction';
|
|
import type {TableColumn} from 'sentry/views/discover/table/types';
|
|
import {
|
|
DisplayModes,
|
|
normalizeSearchConditionsWithTransactionName,
|
|
TransactionFilterOptions,
|
|
transactionSummaryRouteWithQuery,
|
|
} from 'sentry/views/performance/transactionSummary/utils';
|
|
|
|
import {getProjectID, getSelectedProjectPlatforms} from '../utils';
|
|
|
|
import {
|
|
getVitalDetailTableMehStatusFunction,
|
|
getVitalDetailTablePoorStatusFunction,
|
|
vitalAbbreviations,
|
|
vitalNameFromLocation,
|
|
VitalState,
|
|
vitalStateColors,
|
|
} from './utils';
|
|
|
|
const COLUMN_TITLES = ['Transaction', 'Project', 'Unique Users', 'Count'];
|
|
|
|
const getTableColumnTitle = (index: number, vitalName: WebVital) => {
|
|
const abbrev = vitalAbbreviations[vitalName];
|
|
const titles = [
|
|
...COLUMN_TITLES,
|
|
`p50(${abbrev})`,
|
|
`p75(${abbrev})`,
|
|
`p95(${abbrev})`,
|
|
`Status`,
|
|
];
|
|
return titles[index];
|
|
};
|
|
|
|
type Props = {
|
|
eventView: EventView;
|
|
location: Location;
|
|
organization: Organization;
|
|
projects: Project[];
|
|
setError: (msg: string | undefined) => void;
|
|
|
|
summaryConditions: string;
|
|
};
|
|
|
|
type State = {
|
|
widths: number[];
|
|
};
|
|
|
|
class Table extends Component<Props, State> {
|
|
state: State = {
|
|
widths: [],
|
|
};
|
|
|
|
handleCellAction = (column: TableColumn<keyof TableDataRow>) => {
|
|
return (action: Actions, value: React.ReactText) => {
|
|
const {eventView, location, organization} = this.props;
|
|
|
|
trackAnalytics('performance_views.overview.cellaction', {
|
|
organization,
|
|
action,
|
|
});
|
|
|
|
const searchConditions = normalizeSearchConditionsWithTransactionName(
|
|
eventView.query
|
|
);
|
|
|
|
updateQuery(searchConditions, action, column, value);
|
|
|
|
browserHistory.push({
|
|
pathname: location.pathname,
|
|
query: {
|
|
...location.query,
|
|
cursor: undefined,
|
|
query: searchConditions.formatString(),
|
|
},
|
|
});
|
|
};
|
|
};
|
|
|
|
renderBodyCell(
|
|
tableData: TableData | null,
|
|
column: TableColumn<keyof TableDataRow>,
|
|
dataRow: TableDataRow,
|
|
vitalName: WebVital
|
|
): React.ReactNode {
|
|
const {eventView, organization, projects, location, summaryConditions} = this.props;
|
|
|
|
if (!tableData || !tableData.meta?.fields) {
|
|
return dataRow[column.key];
|
|
}
|
|
const tableMeta = tableData.meta?.fields;
|
|
|
|
const field = String(column.key);
|
|
|
|
if (field === getVitalDetailTablePoorStatusFunction(vitalName)) {
|
|
if (dataRow[field]) {
|
|
return (
|
|
<UniqueTagCell>
|
|
<PoorTag>{t('Poor')}</PoorTag>
|
|
</UniqueTagCell>
|
|
);
|
|
}
|
|
if (dataRow[getVitalDetailTableMehStatusFunction(vitalName)]) {
|
|
return (
|
|
<UniqueTagCell>
|
|
<MehTag>{t('Meh')}</MehTag>
|
|
</UniqueTagCell>
|
|
);
|
|
}
|
|
return (
|
|
<UniqueTagCell>
|
|
<GoodTag>{t('Good')}</GoodTag>
|
|
</UniqueTagCell>
|
|
);
|
|
}
|
|
|
|
const fieldRenderer = getFieldRenderer(field, tableMeta, false);
|
|
const rendered = fieldRenderer(dataRow, {organization, location});
|
|
|
|
const allowActions = [
|
|
Actions.ADD,
|
|
Actions.EXCLUDE,
|
|
Actions.SHOW_GREATER_THAN,
|
|
Actions.SHOW_LESS_THAN,
|
|
];
|
|
|
|
if (field === 'transaction') {
|
|
const projectID = getProjectID(dataRow, projects);
|
|
const summaryView = eventView.clone();
|
|
const conditions = new MutableSearch(summaryConditions);
|
|
conditions.addFilterValues('has', [`${vitalName}`]);
|
|
summaryView.query = conditions.formatString();
|
|
|
|
const transaction = String(dataRow.transaction) || '';
|
|
|
|
const target = transactionSummaryRouteWithQuery({
|
|
orgSlug: organization.slug,
|
|
transaction,
|
|
query: summaryView.generateQueryStringObject(),
|
|
projectID,
|
|
showTransactions: TransactionFilterOptions.RECENT,
|
|
display: DisplayModes.VITALS,
|
|
});
|
|
|
|
return (
|
|
<CellAction
|
|
column={column}
|
|
dataRow={dataRow}
|
|
handleCellAction={this.handleCellAction(column)}
|
|
allowActions={allowActions}
|
|
>
|
|
<Link
|
|
to={target}
|
|
aria-label={t('See transaction summary of the transaction %s', transaction)}
|
|
onClick={this.handleSummaryClick}
|
|
>
|
|
{rendered}
|
|
</Link>
|
|
</CellAction>
|
|
);
|
|
}
|
|
|
|
if (field.startsWith('team_key_transaction')) {
|
|
return rendered;
|
|
}
|
|
|
|
return (
|
|
<CellAction
|
|
column={column}
|
|
dataRow={dataRow}
|
|
handleCellAction={this.handleCellAction(column)}
|
|
allowActions={allowActions}
|
|
>
|
|
{rendered}
|
|
</CellAction>
|
|
);
|
|
}
|
|
|
|
renderBodyCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
|
|
return (
|
|
column: TableColumn<keyof TableDataRow>,
|
|
dataRow: TableDataRow
|
|
): React.ReactNode => this.renderBodyCell(tableData, column, dataRow, vitalName);
|
|
};
|
|
|
|
renderHeadCell(
|
|
column: TableColumn<keyof TableDataRow>,
|
|
title: React.ReactNode,
|
|
tableMeta?: EventsMetaType['fields']
|
|
): React.ReactNode {
|
|
const {eventView, location} = this.props;
|
|
// TODO: Need to map table meta keys to aggregate alias since eventView sorting still expects
|
|
// aggregate aliases for now. We'll need to refactor event view to get rid of all aggregate
|
|
// alias references and then we can remove this.
|
|
const aggregateAliasTableMeta: EventsMetaType['fields'] | undefined = tableMeta
|
|
? {}
|
|
: undefined;
|
|
if (tableMeta) {
|
|
Object.keys(tableMeta).forEach(key => {
|
|
aggregateAliasTableMeta![getAggregateAlias(key)] = tableMeta[key];
|
|
});
|
|
}
|
|
|
|
const align = fieldAlignment(column.name, column.type, aggregateAliasTableMeta);
|
|
const field = {field: column.name, width: column.width};
|
|
|
|
function generateSortLink(): LocationDescriptorObject | undefined {
|
|
if (!aggregateAliasTableMeta) {
|
|
return undefined;
|
|
}
|
|
|
|
const nextEventView = eventView.sortOnField(field, aggregateAliasTableMeta);
|
|
const queryStringObject = nextEventView.generateQueryStringObject();
|
|
|
|
return {
|
|
...location,
|
|
query: {...location.query, sort: queryStringObject.sort},
|
|
};
|
|
}
|
|
const currentSort = eventView.sortForField(field, aggregateAliasTableMeta);
|
|
const canSort = isFieldSortable(field, aggregateAliasTableMeta);
|
|
|
|
return (
|
|
<SortLink
|
|
align={align}
|
|
title={title || field.field}
|
|
direction={currentSort ? currentSort.kind : undefined}
|
|
canSort={canSort}
|
|
generateSortLink={generateSortLink}
|
|
/>
|
|
);
|
|
}
|
|
|
|
renderHeadCellWithMeta = (
|
|
vitalName: WebVital,
|
|
tableMeta?: EventsMetaType['fields']
|
|
) => {
|
|
return (column: TableColumn<keyof TableDataRow>, index: number): React.ReactNode =>
|
|
this.renderHeadCell(column, getTableColumnTitle(index, vitalName), tableMeta);
|
|
};
|
|
|
|
renderPrependCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
|
|
const {eventView} = this.props;
|
|
const teamKeyTransactionColumn = eventView
|
|
.getColumns()
|
|
.find((col: TableColumn<React.ReactText>) => col.name === 'team_key_transaction');
|
|
return (isHeader: boolean, dataRow?: any) => {
|
|
if (teamKeyTransactionColumn) {
|
|
if (isHeader) {
|
|
const star = (
|
|
<IconStar
|
|
key="keyTransaction"
|
|
color="yellow400"
|
|
isSolid
|
|
data-test-id="key-transaction-header"
|
|
/>
|
|
);
|
|
return [
|
|
this.renderHeadCell(teamKeyTransactionColumn, star, tableData?.meta?.fields),
|
|
];
|
|
}
|
|
return [
|
|
this.renderBodyCell(tableData, teamKeyTransactionColumn, dataRow, vitalName),
|
|
];
|
|
}
|
|
return [];
|
|
};
|
|
};
|
|
|
|
handleSummaryClick = () => {
|
|
const {organization, projects, location} = this.props;
|
|
|
|
trackAnalytics('performance_views.overview.navigate.summary', {
|
|
organization,
|
|
project_platforms: getSelectedProjectPlatforms(location, projects),
|
|
});
|
|
};
|
|
|
|
handleResizeColumn = (columnIndex: number, nextColumn: GridColumn) => {
|
|
const widths: number[] = [...this.state.widths];
|
|
widths[columnIndex] = nextColumn.width
|
|
? Number(nextColumn.width)
|
|
: COL_WIDTH_UNDEFINED;
|
|
this.setState({widths});
|
|
};
|
|
|
|
getSortedEventView(vitalName: WebVital) {
|
|
const {eventView} = this.props;
|
|
|
|
const aggregateFieldPoor = getAggregateAlias(
|
|
getVitalDetailTablePoorStatusFunction(vitalName)
|
|
);
|
|
const aggregateFieldMeh = getAggregateAlias(
|
|
getVitalDetailTableMehStatusFunction(vitalName)
|
|
);
|
|
const isSortingByStatus = eventView.sorts.some(
|
|
sort =>
|
|
sort.field.includes(aggregateFieldPoor) || sort.field.includes(aggregateFieldMeh)
|
|
);
|
|
|
|
const additionalSorts: Sort[] = isSortingByStatus
|
|
? []
|
|
: [
|
|
{
|
|
field: 'team_key_transaction',
|
|
kind: 'desc',
|
|
},
|
|
{
|
|
field: aggregateFieldPoor,
|
|
kind: 'desc',
|
|
},
|
|
{
|
|
field: aggregateFieldMeh,
|
|
kind: 'desc',
|
|
},
|
|
];
|
|
|
|
return eventView.withSorts([...additionalSorts, ...eventView.sorts]);
|
|
}
|
|
|
|
render() {
|
|
const {eventView, organization, location} = this.props;
|
|
const {widths} = this.state;
|
|
|
|
const fakeColumnView = eventView.clone();
|
|
fakeColumnView.fields = [...eventView.fields];
|
|
const columnOrder = fakeColumnView
|
|
.getColumns()
|
|
// remove key_transactions from the column order as we'll be rendering it
|
|
// via a prepended column
|
|
.filter((col: TableColumn<React.ReactText>) => col.name !== 'team_key_transaction')
|
|
.slice(0, -1)
|
|
.map((col: TableColumn<React.ReactText>, i: number) => {
|
|
if (typeof widths[i] === 'number') {
|
|
return {...col, width: widths[i]};
|
|
}
|
|
return col;
|
|
});
|
|
|
|
const vitalName = vitalNameFromLocation(location);
|
|
const sortedEventView = this.getSortedEventView(vitalName);
|
|
const columnSortBy = sortedEventView.getSorts();
|
|
|
|
return (
|
|
<div>
|
|
<VitalsDetailsTableQuery
|
|
eventView={sortedEventView}
|
|
orgSlug={organization.slug}
|
|
location={location}
|
|
limit={10}
|
|
referrer="api.performance.vital-detail"
|
|
>
|
|
{({pageLinks, isLoading, tableData}) => (
|
|
<Fragment>
|
|
<GridEditable
|
|
isLoading={isLoading}
|
|
data={tableData ? tableData.data : []}
|
|
columnOrder={columnOrder}
|
|
columnSortBy={columnSortBy}
|
|
grid={{
|
|
onResizeColumn: this.handleResizeColumn,
|
|
renderHeadCell: this.renderHeadCellWithMeta(
|
|
vitalName,
|
|
tableData?.meta?.fields
|
|
) as any,
|
|
renderBodyCell: this.renderBodyCellWithData(
|
|
tableData,
|
|
vitalName
|
|
) as any,
|
|
renderPrependColumns: this.renderPrependCellWithData(
|
|
tableData,
|
|
vitalName
|
|
) as any,
|
|
prependColumnWidths: ['max-content'],
|
|
}}
|
|
location={location}
|
|
/>
|
|
<Pagination pageLinks={pageLinks} />
|
|
</Fragment>
|
|
)}
|
|
</VitalsDetailsTableQuery>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
const UniqueTagCell = styled('div')`
|
|
text-align: right;
|
|
justify-self: flex-end;
|
|
flex-grow: 1;
|
|
`;
|
|
|
|
const GoodTag = styled(Tag)`
|
|
div {
|
|
background-color: ${p => p.theme[vitalStateColors[VitalState.GOOD]]};
|
|
}
|
|
span {
|
|
color: ${p => p.theme.white};
|
|
}
|
|
`;
|
|
|
|
const MehTag = styled(Tag)`
|
|
div {
|
|
background-color: ${p => p.theme[vitalStateColors[VitalState.MEH]]};
|
|
}
|
|
span {
|
|
color: ${p => p.theme.white};
|
|
}
|
|
`;
|
|
|
|
const PoorTag = styled(Tag)`
|
|
div {
|
|
background-color: ${p => p.theme[vitalStateColors[VitalState.POOR]]};
|
|
}
|
|
span {
|
|
color: ${p => p.theme.white};
|
|
}
|
|
`;
|
|
|
|
export default Table;
|