widget.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import {memo, useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import colorFn from 'color';
  4. import type {LineSeriesOption} from 'echarts';
  5. import moment from 'moment';
  6. import Alert from 'sentry/components/alert';
  7. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  8. import EmptyMessage from 'sentry/components/emptyMessage';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelBody from 'sentry/components/panels/panelBody';
  13. import {IconSearch} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {MetricsApiResponse, MRI, PageFilters} from 'sentry/types';
  17. import {
  18. getSeriesName,
  19. MetricDisplayType,
  20. MetricWidgetQueryParams,
  21. } from 'sentry/utils/metrics';
  22. import {parseMRI} from 'sentry/utils/metrics/mri';
  23. import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  24. import theme from 'sentry/utils/theme';
  25. import {MetricChart} from 'sentry/views/ddm/chart';
  26. import {MetricWidgetContextMenu} from 'sentry/views/ddm/contextMenu';
  27. import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
  28. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  29. import {MIN_WIDGET_WIDTH} from './constants';
  30. export const MetricWidget = memo(
  31. ({
  32. widget,
  33. datetime,
  34. projects,
  35. environments,
  36. index,
  37. isSelected,
  38. onSelect,
  39. onChange,
  40. }: {
  41. datetime: PageFilters['datetime'];
  42. environments: PageFilters['environments'];
  43. index: number;
  44. isSelected: boolean;
  45. onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  46. onSelect: (index: number) => void;
  47. projects: PageFilters['projects'];
  48. widget: MetricWidgetQueryParams;
  49. }) => {
  50. const handleChange = useCallback(
  51. (data: Partial<MetricWidgetQueryParams>) => {
  52. onChange(index, data);
  53. },
  54. [index, onChange]
  55. );
  56. const metricsQuery = useMemo(
  57. () => ({
  58. mri: widget.mri,
  59. query: widget.query,
  60. op: widget.op,
  61. groupBy: widget.groupBy,
  62. projects,
  63. datetime,
  64. environments,
  65. }),
  66. [widget, projects, datetime, environments]
  67. );
  68. return (
  69. <MetricWidgetPanel isSelected={isSelected} onClick={() => onSelect(index)}>
  70. <PanelBody>
  71. <MetricWidgetHeader>
  72. <QueryBuilder
  73. metricsQuery={metricsQuery}
  74. projects={projects}
  75. displayType={widget.displayType}
  76. onChange={handleChange}
  77. powerUserMode={widget.powerUserMode}
  78. />
  79. <MetricWidgetContextMenu
  80. widgetIndex={index}
  81. metricsQuery={metricsQuery}
  82. displayType={widget.displayType}
  83. />
  84. </MetricWidgetHeader>
  85. {widget.mri ? (
  86. <MetricWidgetBody
  87. datetime={datetime}
  88. projects={projects}
  89. environments={environments}
  90. onChange={handleChange}
  91. {...widget}
  92. />
  93. ) : (
  94. <StyledMetricWidgetBody>
  95. <EmptyMessage
  96. icon={<IconSearch size="xxl" />}
  97. title={t('Nothing to show!')}
  98. description={t('Choose a metric to display data.')}
  99. />
  100. </StyledMetricWidgetBody>
  101. )}
  102. </PanelBody>
  103. </MetricWidgetPanel>
  104. );
  105. }
  106. );
  107. const MetricWidgetHeader = styled('div')`
  108. display: flex;
  109. justify-content: space-between;
  110. margin-bottom: ${space(1)};
  111. `;
  112. interface MetricWidgetProps extends MetricWidgetQueryParams {
  113. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  114. }
  115. const MetricWidgetBody = memo(
  116. ({
  117. onChange,
  118. displayType,
  119. focusedSeries,
  120. sort,
  121. ...metricsQuery
  122. }: MetricWidgetProps & PageFilters) => {
  123. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  124. const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom(
  125. {
  126. mri,
  127. op,
  128. query,
  129. groupBy,
  130. projects,
  131. environments,
  132. datetime,
  133. },
  134. {fidelity: displayType === MetricDisplayType.BAR ? 'low' : 'high'}
  135. );
  136. const [dataToBeRendered, setDataToBeRendered] = useState<
  137. MetricsApiResponse | undefined
  138. >(undefined);
  139. const [hoveredLegend, setHoveredLegend] = useState('');
  140. useEffect(() => {
  141. if (data) {
  142. setDataToBeRendered(data);
  143. }
  144. }, [data]);
  145. const toggleSeriesVisibility = useCallback(
  146. (seriesName: string) => {
  147. setHoveredLegend('');
  148. onChange({
  149. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  150. });
  151. },
  152. [focusedSeries, onChange]
  153. );
  154. if (!dataToBeRendered || isError) {
  155. return (
  156. <StyledMetricWidgetBody>
  157. {isLoading && <LoadingIndicator />}
  158. {isError && (
  159. <Alert type="error">
  160. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  161. </Alert>
  162. )}
  163. </StyledMetricWidgetBody>
  164. );
  165. }
  166. const chartSeries = getChartSeries(dataToBeRendered, {
  167. mri,
  168. focusedSeries,
  169. hoveredLegend,
  170. groupBy: metricsQuery.groupBy,
  171. displayType,
  172. });
  173. return (
  174. <StyledMetricWidgetBody>
  175. <TransparentLoadingMask visible={isLoading} />
  176. <MetricChart
  177. series={chartSeries}
  178. displayType={displayType}
  179. operation={metricsQuery.op}
  180. projects={metricsQuery.projects}
  181. environments={metricsQuery.environments}
  182. {...normalizeChartTimeParams(dataToBeRendered)}
  183. onZoom={onZoom}
  184. />
  185. {metricsQuery.showSummaryTable && (
  186. <SummaryTable
  187. series={chartSeries}
  188. onSortChange={newSort => {
  189. onChange({sort: newSort});
  190. }}
  191. sort={sort}
  192. operation={metricsQuery.op}
  193. onRowClick={toggleSeriesVisibility}
  194. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  195. />
  196. )}
  197. </StyledMetricWidgetBody>
  198. );
  199. }
  200. );
  201. export function getChartSeries(
  202. data: MetricsApiResponse,
  203. {
  204. mri,
  205. focusedSeries,
  206. groupBy,
  207. hoveredLegend,
  208. displayType,
  209. }: {
  210. displayType: MetricDisplayType;
  211. mri: MRI;
  212. focusedSeries?: string;
  213. groupBy?: string[];
  214. hoveredLegend?: string;
  215. }
  216. ) {
  217. // this assumes that all series have the same unit
  218. const parsed = parseMRI(mri);
  219. const unit = parsed?.unit ?? '';
  220. const series = data.groups.map(g => {
  221. return {
  222. values: Object.values(g.series)[0],
  223. name: getSeriesName(g, data.groups.length === 1, groupBy),
  224. transaction: g.by.transaction,
  225. release: g.by.release,
  226. };
  227. });
  228. const colors = getChartColorPalette(displayType, series.length);
  229. return sortSeries(series, displayType).map((item, i) => ({
  230. seriesName: item.name,
  231. unit,
  232. color: colorFn(colors[i])
  233. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  234. .string(),
  235. hidden: focusedSeries && focusedSeries !== item.name,
  236. data: item.values.map((value, index) => ({
  237. name: moment(data.intervals[index]).valueOf(),
  238. value,
  239. })),
  240. transaction: item.transaction as string | undefined,
  241. release: item.release as string | undefined,
  242. emphasis: {
  243. focus: 'series',
  244. } as LineSeriesOption['emphasis'],
  245. })) as Series[];
  246. }
  247. function sortSeries(
  248. series: {
  249. name: string;
  250. release: string;
  251. transaction: string;
  252. values: (number | null)[];
  253. }[],
  254. displayType: MetricDisplayType
  255. ) {
  256. const sorted = series
  257. // we need to sort the series by their values so that the colors in area chart do not overlap
  258. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  259. .sort((a, b) => {
  260. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  261. });
  262. if (displayType === MetricDisplayType.BAR) {
  263. return sorted.toReversed();
  264. }
  265. return sorted;
  266. }
  267. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  268. const palette = theme.charts.getColorPalette(length - 2);
  269. if (displayType === MetricDisplayType.BAR) {
  270. return palette;
  271. }
  272. return palette.toReversed();
  273. }
  274. function normalizeChartTimeParams(data: MetricsApiResponse) {
  275. const {
  276. start,
  277. end,
  278. utc: utcString,
  279. statsPeriod,
  280. } = normalizeDateTimeParams(data, {
  281. allowEmptyPeriod: true,
  282. allowAbsoluteDatetime: true,
  283. allowAbsolutePageDatetime: true,
  284. });
  285. const utc = utcString === 'true';
  286. if (start && end) {
  287. return utc
  288. ? {
  289. start: moment.utc(start).format(),
  290. end: moment.utc(end).format(),
  291. utc,
  292. }
  293. : {
  294. start: moment(start).utc().format(),
  295. end: moment(end).utc().format(),
  296. utc,
  297. };
  298. }
  299. return {
  300. period: statsPeriod ?? '90d',
  301. };
  302. }
  303. export type Series = {
  304. color: string;
  305. data: {name: number; value: number}[];
  306. seriesName: string;
  307. unit: string;
  308. hidden?: boolean;
  309. release?: string;
  310. transaction?: string;
  311. };
  312. const MetricWidgetPanel = styled(Panel)<{isSelected: boolean}>`
  313. padding-bottom: 0;
  314. margin-bottom: 0;
  315. min-width: ${MIN_WIDGET_WIDTH}px;
  316. position: relative;
  317. ${p =>
  318. p.isSelected &&
  319. // Use ::after to avoid layout shifts when the border changes from 1px to 2px
  320. `
  321. &::after {
  322. content: '';
  323. position: absolute;
  324. top: -1px;
  325. left: -1px;
  326. bottom: -1px;
  327. right: -1px;
  328. pointer-events: none;
  329. border: 2px solid ${p.theme.purple300};
  330. border-radius: ${p.theme.borderRadius};
  331. `}
  332. `;
  333. const StyledMetricWidgetBody = styled('div')`
  334. padding: ${space(1)};
  335. display: flex;
  336. flex-direction: column;
  337. justify-content: center;
  338. `;