endpointTable.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import {ReactElement} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useQuery} from '@tanstack/react-query';
  4. import {Location} from 'history';
  5. import moment from 'moment';
  6. import {DateTimeObject} from 'sentry/components/charts/utils';
  7. import Duration from 'sentry/components/duration';
  8. import GridEditable, {
  9. COL_WIDTH_UNDEFINED,
  10. GridColumnHeader,
  11. } from 'sentry/components/gridEditable';
  12. import Link from 'sentry/components/links/link';
  13. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  14. import {Series} from 'sentry/types/echarts';
  15. import Sparkline from 'sentry/views/starfish/components/sparkline';
  16. import {HOST} from 'sentry/views/starfish/utils/constants';
  17. import {useSpansQuery} from 'sentry/views/starfish/utils/useSpansQuery';
  18. import {zeroFillSeries} from 'sentry/views/starfish/utils/zeroFillSeries';
  19. import {EndpointDataRow} from 'sentry/views/starfish/views/endpointDetails';
  20. import {
  21. getEndpointAggregatesQuery,
  22. getEndpointListEventView,
  23. getEndpointListQuery,
  24. } from './queries';
  25. type Props = {
  26. filterOptions: {
  27. action: string;
  28. datetime: DateTimeObject;
  29. domain: string;
  30. transaction: string;
  31. };
  32. location: Location;
  33. onSelect: (row: EndpointDataRow) => void;
  34. columns?: {
  35. key: string;
  36. name: string;
  37. width: number;
  38. }[];
  39. };
  40. export type DataRow = {
  41. count: number;
  42. description: string;
  43. domain: string;
  44. group_id: string;
  45. };
  46. const COLUMN_ORDER = [
  47. {
  48. key: 'description',
  49. name: 'URL',
  50. width: 600,
  51. },
  52. {
  53. key: 'throughput',
  54. name: 'throughput',
  55. width: 200,
  56. },
  57. {
  58. key: 'p50_trend',
  59. name: 'p50 Trend',
  60. width: 200,
  61. },
  62. {
  63. key: 'p50(span.self_time)',
  64. name: 'p50',
  65. width: COL_WIDTH_UNDEFINED,
  66. },
  67. {
  68. key: 'p95(span.self_time)',
  69. name: 'p95',
  70. width: COL_WIDTH_UNDEFINED,
  71. },
  72. {
  73. key: 'count_unique(user)',
  74. name: 'Users',
  75. width: COL_WIDTH_UNDEFINED,
  76. },
  77. {
  78. key: 'count_unique(transaction)',
  79. name: 'Transactions',
  80. width: COL_WIDTH_UNDEFINED,
  81. },
  82. {
  83. key: 'sum(span.self_time)',
  84. name: 'Total Time',
  85. width: COL_WIDTH_UNDEFINED,
  86. },
  87. ];
  88. export default function EndpointTable({
  89. location,
  90. onSelect,
  91. filterOptions,
  92. columns,
  93. }: Props) {
  94. const {isLoading: areEndpointsLoading, data: endpointsData} = useSpansQuery({
  95. queryString: getEndpointListQuery(filterOptions),
  96. eventView: getEndpointListEventView(filterOptions),
  97. initialData: [],
  98. });
  99. const {isLoading: areEndpointAggregatesLoading, data: endpointsThroughputData} =
  100. useQuery({
  101. queryKey: ['endpointAggregates', filterOptions],
  102. queryFn: () =>
  103. fetch(`${HOST}/?query=${getEndpointAggregatesQuery(filterOptions)}`).then(res =>
  104. res.json()
  105. ),
  106. retry: false,
  107. refetchOnWindowFocus: false,
  108. initialData: [],
  109. });
  110. const aggregatesGroupedByURL = {};
  111. endpointsThroughputData.forEach(({description, interval, count, p50, p95}) => {
  112. if (description in aggregatesGroupedByURL) {
  113. aggregatesGroupedByURL[description].push({name: interval, count, p50, p95});
  114. } else {
  115. aggregatesGroupedByURL[description] = [{name: interval, count, p50, p95}];
  116. }
  117. });
  118. const combinedEndpointData = endpointsData.map(data => {
  119. const url = data.description;
  120. const throughputSeries: Series = {
  121. seriesName: 'throughput',
  122. data: aggregatesGroupedByURL[url]?.map(({name, count}) => ({
  123. name,
  124. value: count,
  125. })),
  126. };
  127. const p50Series: Series = {
  128. seriesName: 'p50 Trend',
  129. data: aggregatesGroupedByURL[url]?.map(({name, p50}) => ({
  130. name,
  131. value: p50,
  132. })),
  133. };
  134. const p95Series: Series = {
  135. seriesName: 'p95 Trend',
  136. data: aggregatesGroupedByURL[url]?.map(({name, p95}) => ({
  137. name,
  138. value: p95,
  139. })),
  140. };
  141. const zeroFilledThroughput = zeroFillSeries(
  142. throughputSeries,
  143. moment.duration(12, 'hours')
  144. );
  145. const zeroFilledP50 = zeroFillSeries(p50Series, moment.duration(12, 'hours'));
  146. const zeroFilledP95 = zeroFillSeries(p95Series, moment.duration(12, 'hours'));
  147. return {
  148. ...data,
  149. throughput: zeroFilledThroughput,
  150. p50_trend: zeroFilledP50,
  151. p95_trend: zeroFilledP95,
  152. };
  153. });
  154. return (
  155. <GridEditable
  156. isLoading={areEndpointsLoading || areEndpointAggregatesLoading}
  157. data={combinedEndpointData}
  158. columnOrder={columns ?? COLUMN_ORDER}
  159. columnSortBy={[]}
  160. grid={{
  161. renderHeadCell,
  162. renderBodyCell: (column: GridColumnHeader, row: EndpointDataRow) =>
  163. renderBodyCell(column, row, onSelect),
  164. }}
  165. location={location}
  166. />
  167. );
  168. }
  169. export function renderHeadCell(column: GridColumnHeader): React.ReactNode {
  170. if (column.key === 'throughput' || column.key === 'p50_trend') {
  171. return (
  172. <TextAlignLeft>
  173. <OverflowEllipsisTextContainer>{column.name}</OverflowEllipsisTextContainer>
  174. </TextAlignLeft>
  175. );
  176. }
  177. // TODO: come up with a better way to identify number columns to align to the right
  178. if (
  179. column.key.toString().match(/^p\d\d/) ||
  180. !['description', 'transaction'].includes(column.key.toString())
  181. ) {
  182. return (
  183. <TextAlignRight>
  184. <OverflowEllipsisTextContainer>{column.name}</OverflowEllipsisTextContainer>
  185. </TextAlignRight>
  186. );
  187. }
  188. return <OverflowEllipsisTextContainer>{column.name}</OverflowEllipsisTextContainer>;
  189. }
  190. export function renderBodyCell(
  191. column: GridColumnHeader,
  192. row: EndpointDataRow,
  193. onSelect?: (row: EndpointDataRow) => void
  194. ): React.ReactNode {
  195. if (column.key === 'description' && onSelect) {
  196. return (
  197. <OverflowEllipsisTextContainer>
  198. <Link onClick={() => onSelect(row)} to="">
  199. {row[column.key]}
  200. </Link>
  201. </OverflowEllipsisTextContainer>
  202. );
  203. }
  204. if (column.key === 'throughput') {
  205. return (
  206. <GraphRow>
  207. <span>{row.count.toFixed(2)}</span>
  208. <Sparkline
  209. color={CHART_PALETTE[3][0]}
  210. series={row[column.key]}
  211. width={column.width ? column.width - column.width / 5 : undefined}
  212. />
  213. </GraphRow>
  214. );
  215. }
  216. if (column.key === 'p50_trend') {
  217. return (
  218. <GraphRow>
  219. <span>{row['p50(span.self_time)'].toFixed(2)}</span>
  220. <Graphline>
  221. <Sparkline
  222. color={CHART_PALETTE[3][1]}
  223. series={row[column.key]}
  224. width={column.width ? column.width - column.width / 5 - 50 : undefined}
  225. />
  226. </Graphline>
  227. </GraphRow>
  228. );
  229. }
  230. if (column.key === 'p95_trend') {
  231. return (
  232. <GraphRow>
  233. <span>{row['p95(span.self_time)'].toFixed(2)}</span>
  234. <Graphline>
  235. <Sparkline
  236. color={CHART_PALETTE[3][2]}
  237. series={row[column.key]}
  238. width={column.width ? column.width - column.width / 5 - 50 : undefined}
  239. />
  240. </Graphline>
  241. </GraphRow>
  242. );
  243. }
  244. // TODO: come up with a better way to identify number columns to align to the right
  245. let node: ReactElement | null = null;
  246. if (column.key.toString().match(/^p\d\d/) || column.key === 'sum(span.self_time)') {
  247. node = <Duration seconds={row[column.key] / 1000} fixedDigits={2} abbreviation />;
  248. } else if (!['description', 'transaction'].includes(column.key.toString())) {
  249. node = (
  250. <OverflowEllipsisTextContainer>{row[column.key]}</OverflowEllipsisTextContainer>
  251. );
  252. } else {
  253. node = (
  254. <OverflowEllipsisTextContainer>{row[column.key]}</OverflowEllipsisTextContainer>
  255. );
  256. }
  257. const isNumericColumn =
  258. column.key.toString().match(/^p\d\d/) || column.key.toString().match(/^.*\(.*\)/);
  259. if (isNumericColumn) {
  260. return <TextAlignRight>{node}</TextAlignRight>;
  261. }
  262. return <TextAlignLeft>{node}</TextAlignLeft>;
  263. }
  264. export const OverflowEllipsisTextContainer = styled('span')`
  265. text-overflow: ellipsis;
  266. overflow: hidden;
  267. white-space: nowrap;
  268. `;
  269. export const TextAlignRight = styled('span')`
  270. text-align: right;
  271. width: 100%;
  272. `;
  273. export const TextAlignLeft = styled('span')`
  274. text-align: left;
  275. width: 100%;
  276. `;
  277. const Graphline = styled('div')`
  278. margin-left: auto;
  279. `;
  280. const GraphRow = styled('div')`
  281. display: inline-flex;
  282. `;