hostTable.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. import {ReactNode} from 'react';
  2. import {Theme, useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {useQuery} from '@tanstack/react-query';
  5. import {Location} from 'history';
  6. import groupBy from 'lodash/groupBy';
  7. import orderBy from 'lodash/orderBy';
  8. import moment from 'moment';
  9. import GridEditable, {
  10. COL_WIDTH_UNDEFINED,
  11. GridColumnHeader,
  12. } from 'sentry/components/gridEditable';
  13. import {space} from 'sentry/styles/space';
  14. import {Series} from 'sentry/types/echarts';
  15. import {getDuration} from 'sentry/utils/formatters';
  16. import usePageFilters from 'sentry/utils/usePageFilters';
  17. import Sparkline from 'sentry/views/starfish/components/sparkline';
  18. import {INTERNAL_API_REGEX} from 'sentry/views/starfish/modules/APIModule/constants';
  19. import {HOST} from 'sentry/views/starfish/utils/constants';
  20. import {zeroFillSeries} from 'sentry/views/starfish/utils/zeroFillSeries';
  21. import {getEndpointDomainsQuery, getHostListQuery} from './queries';
  22. type Props = {
  23. location: Location;
  24. setDomainFilter: (domain: string) => void;
  25. };
  26. type HostTableRow = {
  27. duration: Series;
  28. failure_rate: Series;
  29. host: string;
  30. max: number;
  31. p50: number;
  32. p95: number;
  33. p99: number;
  34. };
  35. const COLUMN_ORDER = [
  36. {
  37. key: 'host',
  38. name: 'Host',
  39. width: COL_WIDTH_UNDEFINED,
  40. },
  41. {
  42. key: 'duration',
  43. name: 'Response Time',
  44. width: 220,
  45. },
  46. {
  47. key: 'failure_rate',
  48. name: 'Failure Rate',
  49. width: 220,
  50. },
  51. {
  52. key: 'p50',
  53. name: 'P50',
  54. width: 200,
  55. },
  56. {
  57. key: 'p95',
  58. name: 'P95',
  59. width: 200,
  60. },
  61. {
  62. key: 'total_exclusive_time',
  63. name: 'Total Exclusive Time',
  64. width: 200,
  65. },
  66. ];
  67. export default function HostTable({location, setDomainFilter}: Props) {
  68. const pageFilter = usePageFilters();
  69. const theme = useTheme();
  70. const queryString = getHostListQuery({
  71. datetime: pageFilter.selection.datetime,
  72. });
  73. const aggregateQueryString = getEndpointDomainsQuery({
  74. datetime: pageFilter.selection.datetime,
  75. });
  76. const {isLoading: areHostsLoading, data: hostsData} = useQuery({
  77. queryKey: ['query', pageFilter.selection.datetime],
  78. queryFn: () => fetch(`${HOST}/?query=${queryString}`).then(res => res.json()),
  79. retry: false,
  80. refetchOnWindowFocus: false,
  81. initialData: [],
  82. });
  83. const {isLoading: areHostAggregatesLoading, data: aggregateHostsData} = useQuery({
  84. queryKey: ['aggregateQuery', pageFilter.selection.datetime],
  85. queryFn: () =>
  86. fetch(`${HOST}/?query=${aggregateQueryString}`).then(res => res.json()),
  87. retry: false,
  88. refetchOnWindowFocus: false,
  89. initialData: [],
  90. });
  91. const dataByHost = groupBy(hostsData, 'domain');
  92. // Filter out localhost and any IP addresses (probably an internal service)
  93. const hosts = Object.keys(dataByHost).filter(host => !host.match(INTERNAL_API_REGEX));
  94. const startDate = moment(orderBy(hostsData, 'interval', 'asc')[0]?.interval);
  95. const endDate = moment(orderBy(hostsData, 'interval', 'desc')[0]?.interval);
  96. let totalTotalExclusiveTime = 0;
  97. let totalP50 = 0;
  98. let totalP95 = 0;
  99. const tableData: HostTableRow[] = hosts
  100. .map(host => {
  101. const durationSeries: Series = zeroFillSeries(
  102. {
  103. seriesName: host,
  104. data: dataByHost[host].map(datum => ({
  105. name: datum.interval,
  106. value: datum.p99,
  107. })),
  108. },
  109. moment.duration(12, 'hours'),
  110. startDate,
  111. endDate
  112. );
  113. const failureRateSeries: Series = zeroFillSeries(
  114. {
  115. seriesName: host,
  116. data: dataByHost[host].map(datum => ({
  117. name: datum.interval,
  118. value: datum.failure_rate,
  119. })),
  120. },
  121. moment.duration(12, 'hours'),
  122. startDate,
  123. endDate
  124. );
  125. const {
  126. 'p50(span.self_time)': p50,
  127. 'p99(span.self_time)': p99,
  128. 'p95(span.self_time)': p95,
  129. 'p100(span.self_time)': max,
  130. 'sum(span.self_time)': total_exclusive_time,
  131. } = aggregateHostsData?.find(aggregate => aggregate.domain === host) ?? {};
  132. totalTotalExclusiveTime += total_exclusive_time;
  133. totalP50 += p50;
  134. totalP95 += p95;
  135. return {
  136. host,
  137. duration: durationSeries,
  138. failure_rate: failureRateSeries,
  139. p50,
  140. p99,
  141. p95,
  142. max,
  143. total_exclusive_time,
  144. };
  145. })
  146. .filter(row => {
  147. return row.duration.data.length > 0;
  148. })
  149. .sort((a, b) => b.total_exclusive_time - a.total_exclusive_time);
  150. return (
  151. <GridEditable
  152. isLoading={areHostsLoading || areHostAggregatesLoading}
  153. data={tableData}
  154. columnOrder={COLUMN_ORDER}
  155. columnSortBy={[]}
  156. grid={{
  157. renderHeadCell,
  158. renderBodyCell: (column: GridColumnHeader, row: HostTableRow) =>
  159. renderBodyCell({
  160. column,
  161. row,
  162. theme,
  163. totalTotalExclusiveTime,
  164. totalP50,
  165. totalP95,
  166. setDomainFilter,
  167. }),
  168. }}
  169. location={location}
  170. height={400}
  171. scrollable
  172. stickyHeader
  173. />
  174. );
  175. }
  176. function renderHeadCell(column: GridColumnHeader): React.ReactNode {
  177. return column.name;
  178. }
  179. function renderBodyCell({
  180. column,
  181. row,
  182. theme,
  183. totalTotalExclusiveTime,
  184. totalP50,
  185. totalP95,
  186. setDomainFilter,
  187. }: {
  188. column: GridColumnHeader;
  189. row: HostTableRow;
  190. setDomainFilter: (domain: string) => void;
  191. theme: Theme;
  192. totalP50: number;
  193. totalP95: number;
  194. totalTotalExclusiveTime: number;
  195. }): React.ReactNode {
  196. if (column.key === 'host') {
  197. return <a onClick={() => setDomainFilter(row.host)}>{row[column.key]}</a>;
  198. }
  199. if (column.key === 'duration') {
  200. const series: Series = row[column.key];
  201. if (series) {
  202. return <Sparkline color="rgb(242, 183, 18)" series={series} />;
  203. }
  204. return 'Loading';
  205. }
  206. if (column.key === 'failure_rate') {
  207. const series: Series = row[column.key];
  208. if (series) {
  209. return <Sparkline color="#ef7061" series={series} />;
  210. }
  211. return 'Loading';
  212. }
  213. if (column.key === 'total_exclusive_time') {
  214. return (
  215. <MeterBar
  216. minWidth={0.1}
  217. meterItems={['total_exclusive_time']}
  218. row={row}
  219. total={totalTotalExclusiveTime}
  220. color={theme.green300}
  221. />
  222. );
  223. }
  224. if (column.key === 'p50') {
  225. return (
  226. <MeterBar
  227. minWidth={0.1}
  228. meterItems={['p50']}
  229. row={row}
  230. total={totalP50}
  231. color={theme.blue300}
  232. />
  233. );
  234. }
  235. if (column.key === 'p95') {
  236. return (
  237. <MeterBar
  238. minWidth={0.1}
  239. meterItems={['p95']}
  240. row={row}
  241. total={totalP95}
  242. color={theme.red300}
  243. />
  244. );
  245. }
  246. return row[column.key];
  247. }
  248. export function MeterBar({
  249. minWidth,
  250. meterItems,
  251. row,
  252. total,
  253. color,
  254. meterText,
  255. }: {
  256. color: string;
  257. meterItems: string[];
  258. minWidth: number;
  259. row: any;
  260. total: number;
  261. meterText?: ReactNode;
  262. }) {
  263. const widths = [] as number[];
  264. meterItems.reduce((acc, item, index) => {
  265. const width = Math.max(
  266. Math.min(
  267. (100 * row[item]) / total - acc,
  268. 100 - acc - minWidth * (meterItems.length - index)
  269. ),
  270. minWidth
  271. );
  272. widths.push(width);
  273. return acc + width;
  274. }, 0);
  275. return (
  276. <span>
  277. <MeterText>
  278. {meterText ?? `${getDuration(row[meterItems[0]] / 1000, 0, true, true)}`}
  279. </MeterText>
  280. <MeterContainer width={100}>
  281. <Meter width={widths[0]} color={color} />
  282. </MeterContainer>
  283. </span>
  284. );
  285. }
  286. const MeterContainer = styled('span')<{width: number}>`
  287. display: flex;
  288. width: ${p => p.width}%;
  289. height: ${space(1)};
  290. background-color: ${p => p.theme.gray100};
  291. margin-bottom: 4px;
  292. `;
  293. const Meter = styled('span')<{
  294. color: string;
  295. width: number;
  296. }>`
  297. display: block;
  298. width: ${p => p.width}%;
  299. height: 100%;
  300. background-color: ${p => p.color};
  301. `;
  302. const MeterText = styled('span')`
  303. font-size: ${p => p.theme.fontSizeExtraSmall};
  304. color: ${p => p.theme.gray300};
  305. white-space: nowrap;
  306. `;