endpointList.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Location, LocationDescriptorObject} from 'history';
  4. import * as qs from 'query-string';
  5. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  6. import Duration from 'sentry/components/duration';
  7. import GridEditable, {
  8. COL_WIDTH_UNDEFINED,
  9. GridColumn,
  10. } from 'sentry/components/gridEditable';
  11. import SortLink, {Alignments} from 'sentry/components/gridEditable/sortLink';
  12. import Link from 'sentry/components/links/link';
  13. import Pagination from 'sentry/components/pagination';
  14. import BaseSearchBar from 'sentry/components/searchBar';
  15. import {Tooltip} from 'sentry/components/tooltip';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {Organization, Project} from 'sentry/types';
  19. import DiscoverQuery, {
  20. TableData,
  21. TableDataRow,
  22. } from 'sentry/utils/discover/discoverQuery';
  23. import EventView, {isFieldSortable, MetaType} from 'sentry/utils/discover/eventView';
  24. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  25. import {getAggregateAlias} from 'sentry/utils/discover/fields';
  26. import {NumberContainer} from 'sentry/utils/discover/styles';
  27. import {formatPercentage} from 'sentry/utils/formatters';
  28. import {TableColumn} from 'sentry/views/discover/table/types';
  29. import ThroughputCell from 'sentry/views/starfish/components/tableCells/throughputCell';
  30. import {TIME_SPENT_IN_SERVICE} from 'sentry/views/starfish/utils/generatePerformanceEventView';
  31. import {DataTitles} from 'sentry/views/starfish/views/spans/types';
  32. const COLUMN_TITLES = [
  33. t('Endpoint'),
  34. DataTitles.throughput,
  35. t('Change'),
  36. DataTitles.p95,
  37. t('Change'),
  38. DataTitles.errorCount,
  39. t('Change'),
  40. DataTitles.timeSpent,
  41. ];
  42. type Props = {
  43. eventView: EventView;
  44. location: Location;
  45. organization: Organization;
  46. projects: Project[];
  47. setError: (msg: string | undefined) => void;
  48. };
  49. function EndpointList({eventView, location, organization, setError}: Props) {
  50. const [widths, setWidths] = useState<number[]>([]);
  51. const [_eventView, setEventView] = useState<EventView>(eventView);
  52. // Effect to keep the parent eventView in sync with the child, so that chart zoom and time period can be accounted for.
  53. useEffect(() => {
  54. setEventView(prevEventView => {
  55. const cloned = eventView.clone();
  56. cloned.query = prevEventView.query;
  57. return cloned;
  58. });
  59. }, [eventView]);
  60. function renderBodyCell(
  61. tableData: TableData | null,
  62. column: TableColumn<keyof TableDataRow>,
  63. dataRow: TableDataRow,
  64. _deltaColumnMap: Record<string, string>
  65. ): React.ReactNode {
  66. if (!tableData || !tableData.meta) {
  67. return dataRow[column.key];
  68. }
  69. const tableMeta = tableData.meta;
  70. const field = String(column.key);
  71. const fieldRenderer = getFieldRenderer(field, tableMeta, false);
  72. const rendered = fieldRenderer(dataRow, {organization, location});
  73. if (field === 'transaction') {
  74. let prefix = '';
  75. if (dataRow['http.method']) {
  76. prefix = `${dataRow['http.method']} `;
  77. }
  78. return (
  79. <Link
  80. to={`/organizations/${
  81. organization.slug
  82. }/starfish/endpoint-overview/?${qs.stringify({
  83. endpoint: dataRow.transaction,
  84. 'http.method': dataRow['http.method'],
  85. statsPeriod: eventView.statsPeriod,
  86. project: eventView.project,
  87. start: eventView.start,
  88. end: eventView.end,
  89. })}`}
  90. style={{display: `block`, width: `100%`}}
  91. >
  92. {prefix}
  93. {dataRow.transaction}
  94. </Link>
  95. );
  96. }
  97. if (field === TIME_SPENT_IN_SERVICE) {
  98. const cumulativeTime = Number(dataRow['sum(transaction.duration)']);
  99. const cumulativeTimePercentage = Number(dataRow[TIME_SPENT_IN_SERVICE]);
  100. return (
  101. <Tooltip
  102. title={tct('Total time spent by endpoint is [cumulativeTime])', {
  103. cumulativeTime: (
  104. <Duration seconds={cumulativeTime / 1000} fixedDigits={2} abbreviation />
  105. ),
  106. })}
  107. containerDisplayMode="block"
  108. position="top"
  109. >
  110. <NumberContainer>{formatPercentage(cumulativeTimePercentage)}</NumberContainer>
  111. </Tooltip>
  112. );
  113. }
  114. // TODO: This can be removed if/when the backend returns this field's type
  115. // as `"rate"` and its unit as `"1/second"
  116. if (field === 'tps()') {
  117. return <ThroughputCell throughputPerSecond={dataRow[field] as number} />;
  118. }
  119. if (field === 'project') {
  120. return null;
  121. }
  122. const fieldName = getAggregateAlias(field);
  123. const value = dataRow[fieldName];
  124. if (tableMeta[fieldName] === 'integer' && typeof value === 'number' && value > 999) {
  125. return (
  126. <Tooltip
  127. title={value.toLocaleString()}
  128. containerDisplayMode="block"
  129. position="right"
  130. >
  131. {rendered}
  132. </Tooltip>
  133. );
  134. }
  135. return rendered;
  136. }
  137. function renderBodyCellWithData(tableData: TableData | null) {
  138. const deltaColumnMap: Record<string, string> = {};
  139. if (tableData?.data?.[0]) {
  140. Object.keys(tableData.data[0]).forEach(col => {
  141. if (
  142. col.startsWith(
  143. 'equation|(percentile_range(transaction.duration,0.95,lessOrEquals'
  144. )
  145. ) {
  146. deltaColumnMap['p95()'] = col;
  147. }
  148. });
  149. }
  150. return (
  151. column: TableColumn<keyof TableDataRow>,
  152. dataRow: TableDataRow
  153. ): React.ReactNode => renderBodyCell(tableData, column, dataRow, deltaColumnMap);
  154. }
  155. function renderHeadCell(
  156. tableMeta: TableData['meta'],
  157. column: TableColumn<keyof TableDataRow>,
  158. title: React.ReactNode
  159. ): React.ReactNode {
  160. let align: Alignments = 'right';
  161. if (title === 'Endpoint') {
  162. align = 'left';
  163. }
  164. const field = {
  165. field: column.column.kind === 'equation' ? (column.key as string) : column.name,
  166. width: column.width,
  167. };
  168. const aggregateAliasTableMeta: MetaType = {};
  169. if (tableMeta) {
  170. Object.keys(tableMeta).forEach(key => {
  171. aggregateAliasTableMeta[getAggregateAlias(key)] = tableMeta[key];
  172. });
  173. }
  174. function generateSortLink(): LocationDescriptorObject | undefined {
  175. if (!tableMeta) {
  176. return undefined;
  177. }
  178. const nextEventView = eventView.sortOnField(field, aggregateAliasTableMeta);
  179. const queryStringObject = nextEventView.generateQueryStringObject();
  180. return {
  181. ...location,
  182. query: {...location.query, sort: queryStringObject.sort},
  183. };
  184. }
  185. const currentSort = eventView.sortForField(field, aggregateAliasTableMeta);
  186. const canSort = isFieldSortable(field, aggregateAliasTableMeta);
  187. const currentSortKind = currentSort ? currentSort.kind : undefined;
  188. const sortLink = (
  189. <SortLink
  190. align={align}
  191. title={title || field.field}
  192. direction={currentSortKind}
  193. canSort={canSort}
  194. generateSortLink={generateSortLink}
  195. />
  196. );
  197. return sortLink;
  198. }
  199. function renderHeadCellWithMeta(tableMeta: TableData['meta']) {
  200. const newColumnTitles = COLUMN_TITLES;
  201. return (column: TableColumn<keyof TableDataRow>, index: number): React.ReactNode =>
  202. renderHeadCell(tableMeta, column, newColumnTitles[index]);
  203. }
  204. function handleResizeColumn(columnIndex: number, nextColumn: GridColumn) {
  205. setWidths(prevWidths =>
  206. prevWidths.map((width, index) =>
  207. index === columnIndex ? Number(nextColumn.width ?? COL_WIDTH_UNDEFINED) : width
  208. )
  209. );
  210. }
  211. function handleSearch(query: string) {
  212. const clonedEventView = eventView.clone();
  213. // Default to fuzzy finding for now
  214. clonedEventView.query += `transaction:*${query}*`;
  215. setEventView(clonedEventView);
  216. }
  217. const columnOrder = eventView
  218. .getColumns()
  219. .filter(
  220. (col: TableColumn<React.ReactText>) =>
  221. col.name !== 'project' &&
  222. col.name !== 'http.method' &&
  223. col.name !== 'total.transaction_duration' &&
  224. col.name !== 'sum(transaction.duration)'
  225. )
  226. .map((col: TableColumn<React.ReactText>, i: number) => {
  227. if (typeof widths[i] === 'number') {
  228. return {...col, width: widths[i]};
  229. }
  230. return col;
  231. });
  232. const columnSortBy = eventView.getSorts();
  233. return (
  234. <GuideAnchor target="performance_table" position="top-start">
  235. <StyledSearchBar placeholder={t('Search for endpoints')} onSearch={handleSearch} />
  236. <DiscoverQuery
  237. eventView={_eventView}
  238. orgSlug={organization.slug}
  239. location={location}
  240. setError={error => setError(error?.message)}
  241. referrer="api.starfish.endpoint-list"
  242. queryExtras={{dataset: 'metrics'}}
  243. >
  244. {({pageLinks, isLoading, tableData}) => (
  245. <Fragment>
  246. <GridEditable
  247. isLoading={isLoading}
  248. data={tableData ? tableData.data : []}
  249. columnOrder={columnOrder}
  250. columnSortBy={columnSortBy}
  251. grid={{
  252. onResizeColumn: handleResizeColumn,
  253. renderHeadCell: renderHeadCellWithMeta(tableData?.meta) as any,
  254. renderBodyCell: renderBodyCellWithData(tableData) as any,
  255. }}
  256. location={location}
  257. />
  258. <Pagination pageLinks={pageLinks} />
  259. </Fragment>
  260. )}
  261. </DiscoverQuery>
  262. </GuideAnchor>
  263. );
  264. }
  265. export default EndpointList;
  266. const StyledSearchBar = styled(BaseSearchBar)`
  267. margin-bottom: ${space(2)};
  268. `;