widget.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  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, PageFilters} from 'sentry/types';
  17. import {
  18. getSeriesName,
  19. MetricDisplayType,
  20. MetricWidgetQueryParams,
  21. } from 'sentry/utils/metrics';
  22. import {getMRI, 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. focusedSeries,
  168. hoveredLegend,
  169. groupBy: metricsQuery.groupBy,
  170. displayType,
  171. });
  172. return (
  173. <StyledMetricWidgetBody>
  174. <TransparentLoadingMask visible={isLoading} />
  175. <MetricChart
  176. series={chartSeries}
  177. displayType={displayType}
  178. operation={metricsQuery.op}
  179. projects={metricsQuery.projects}
  180. environments={metricsQuery.environments}
  181. {...normalizeChartTimeParams(dataToBeRendered)}
  182. onZoom={onZoom}
  183. />
  184. {metricsQuery.showSummaryTable && (
  185. <SummaryTable
  186. series={chartSeries}
  187. onSortChange={newSort => {
  188. onChange({sort: newSort});
  189. }}
  190. sort={sort}
  191. operation={metricsQuery.op}
  192. onRowClick={toggleSeriesVisibility}
  193. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  194. />
  195. )}
  196. </StyledMetricWidgetBody>
  197. );
  198. }
  199. );
  200. export function getChartSeries(
  201. data: MetricsApiResponse,
  202. {focusedSeries, groupBy, hoveredLegend, displayType}
  203. ) {
  204. // this assumes that all series have the same unit
  205. const mri = getMRI(Object.keys(data.groups[0]?.series ?? {})[0]);
  206. const parsed = parseMRI(mri);
  207. const unit = parsed?.unit ?? '';
  208. const series = data.groups.map(g => {
  209. return {
  210. values: Object.values(g.series)[0],
  211. name: getSeriesName(g, data.groups.length === 1, groupBy),
  212. transaction: g.by.transaction,
  213. release: g.by.release,
  214. };
  215. });
  216. const colors = getChartColorPalette(displayType, series.length);
  217. return sortSeries(series, displayType).map((item, i) => ({
  218. seriesName: item.name,
  219. unit,
  220. color: colorFn(colors[i])
  221. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  222. .string(),
  223. hidden: focusedSeries && focusedSeries !== item.name,
  224. data: item.values.map((value, index) => ({
  225. name: moment(data.intervals[index]).valueOf(),
  226. value,
  227. })),
  228. transaction: item.transaction as string | undefined,
  229. release: item.release as string | undefined,
  230. emphasis: {
  231. focus: 'series',
  232. } as LineSeriesOption['emphasis'],
  233. })) as Series[];
  234. }
  235. function sortSeries(
  236. series: {
  237. name: string;
  238. release: string;
  239. transaction: string;
  240. values: (number | null)[];
  241. }[],
  242. displayType: MetricDisplayType
  243. ) {
  244. const sorted = series
  245. // we need to sort the series by their values so that the colors in area chart do not overlap
  246. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  247. .sort((a, b) => {
  248. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  249. });
  250. if (displayType === MetricDisplayType.BAR) {
  251. return sorted.toReversed();
  252. }
  253. return sorted;
  254. }
  255. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  256. const palette = theme.charts.getColorPalette(length - 2);
  257. if (displayType === MetricDisplayType.BAR) {
  258. return palette;
  259. }
  260. return palette.toReversed();
  261. }
  262. function normalizeChartTimeParams(data: MetricsApiResponse) {
  263. const {
  264. start,
  265. end,
  266. utc: utcString,
  267. statsPeriod,
  268. } = normalizeDateTimeParams(data, {
  269. allowEmptyPeriod: true,
  270. allowAbsoluteDatetime: true,
  271. allowAbsolutePageDatetime: true,
  272. });
  273. const utc = utcString === 'true';
  274. if (start && end) {
  275. return utc
  276. ? {
  277. start: moment.utc(start).format(),
  278. end: moment.utc(end).format(),
  279. utc,
  280. }
  281. : {
  282. start: moment(start).utc().format(),
  283. end: moment(end).utc().format(),
  284. utc,
  285. };
  286. }
  287. return {
  288. period: statsPeriod ?? '90d',
  289. };
  290. }
  291. export type Series = {
  292. color: string;
  293. data: {name: number; value: number}[];
  294. seriesName: string;
  295. unit: string;
  296. hidden?: boolean;
  297. release?: string;
  298. transaction?: string;
  299. };
  300. const MetricWidgetPanel = styled(Panel)<{isSelected: boolean}>`
  301. padding-bottom: 0;
  302. margin-bottom: 0;
  303. min-width: ${MIN_WIDGET_WIDTH}px;
  304. position: relative;
  305. ${p =>
  306. p.isSelected &&
  307. // Use ::after to avoid layout shifts when the border changes from 1px to 2px
  308. `
  309. &::after {
  310. content: '';
  311. position: absolute;
  312. top: -1px;
  313. left: -1px;
  314. bottom: -1px;
  315. right: -1px;
  316. pointer-events: none;
  317. border: 2px solid ${p.theme.purple300};
  318. border-radius: ${p.theme.borderRadius};
  319. `}
  320. `;
  321. const StyledMetricWidgetBody = styled('div')`
  322. padding: ${space(1)};
  323. display: flex;
  324. flex-direction: column;
  325. justify-content: center;
  326. `;