widget.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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. metricsQuery={metricsQuery}
  81. displayType={widget.displayType}
  82. />
  83. </MetricWidgetHeader>
  84. {widget.mri ? (
  85. <MetricWidgetBody
  86. datetime={datetime}
  87. projects={projects}
  88. environments={environments}
  89. onChange={handleChange}
  90. {...widget}
  91. />
  92. ) : (
  93. <StyledMetricWidgetBody>
  94. <EmptyMessage
  95. icon={<IconSearch size="xxl" />}
  96. title={t('Nothing to show!')}
  97. description={t('Choose a metric to display data.')}
  98. />
  99. </StyledMetricWidgetBody>
  100. )}
  101. </PanelBody>
  102. </MetricWidgetPanel>
  103. );
  104. }
  105. );
  106. const MetricWidgetHeader = styled('div')`
  107. display: flex;
  108. justify-content: space-between;
  109. margin-bottom: ${space(1)};
  110. `;
  111. interface MetricWidgetProps extends MetricWidgetQueryParams {
  112. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  113. }
  114. const MetricWidgetBody = memo(
  115. ({
  116. onChange,
  117. displayType,
  118. focusedSeries,
  119. sort,
  120. ...metricsQuery
  121. }: MetricWidgetProps & PageFilters) => {
  122. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  123. const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom(
  124. {
  125. mri,
  126. op,
  127. query,
  128. groupBy,
  129. projects,
  130. environments,
  131. datetime,
  132. },
  133. {fidelity: displayType === MetricDisplayType.BAR ? 'low' : 'high'}
  134. );
  135. const [dataToBeRendered, setDataToBeRendered] = useState<
  136. MetricsApiResponse | undefined
  137. >(undefined);
  138. const [hoveredLegend, setHoveredLegend] = useState('');
  139. useEffect(() => {
  140. if (data) {
  141. setDataToBeRendered(data);
  142. }
  143. }, [data]);
  144. const toggleSeriesVisibility = useCallback(
  145. (seriesName: string) => {
  146. setHoveredLegend('');
  147. onChange({
  148. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  149. });
  150. },
  151. [focusedSeries, onChange]
  152. );
  153. if (!dataToBeRendered || isError) {
  154. return (
  155. <StyledMetricWidgetBody>
  156. {isLoading && <LoadingIndicator />}
  157. {isError && (
  158. <Alert type="error">
  159. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  160. </Alert>
  161. )}
  162. </StyledMetricWidgetBody>
  163. );
  164. }
  165. const chartSeries = getChartSeries(dataToBeRendered, {
  166. focusedSeries,
  167. hoveredLegend,
  168. groupBy: metricsQuery.groupBy,
  169. displayType,
  170. });
  171. return (
  172. <StyledMetricWidgetBody>
  173. <TransparentLoadingMask visible={isLoading} />
  174. <MetricChart
  175. series={chartSeries}
  176. displayType={displayType}
  177. operation={metricsQuery.op}
  178. projects={metricsQuery.projects}
  179. environments={metricsQuery.environments}
  180. {...normalizeChartTimeParams(dataToBeRendered)}
  181. onZoom={onZoom}
  182. />
  183. {metricsQuery.showSummaryTable && (
  184. <SummaryTable
  185. series={chartSeries}
  186. onSortChange={newSort => {
  187. onChange({sort: newSort});
  188. }}
  189. sort={sort}
  190. operation={metricsQuery.op}
  191. onRowClick={toggleSeriesVisibility}
  192. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  193. />
  194. )}
  195. </StyledMetricWidgetBody>
  196. );
  197. }
  198. );
  199. export function getChartSeries(
  200. data: MetricsApiResponse,
  201. {focusedSeries, groupBy, hoveredLegend, displayType}
  202. ) {
  203. // this assumes that all series have the same unit
  204. const mri = getMRI(Object.keys(data.groups[0]?.series ?? {})[0]);
  205. const parsed = parseMRI(mri);
  206. const unit = parsed?.unit ?? '';
  207. const series = data.groups.map(g => {
  208. return {
  209. values: Object.values(g.series)[0],
  210. name: getSeriesName(g, data.groups.length === 1, groupBy),
  211. transaction: g.by.transaction,
  212. release: g.by.release,
  213. };
  214. });
  215. const colors = getChartColorPalette(displayType, series.length);
  216. return sortSeries(series, displayType).map((item, i) => ({
  217. seriesName: item.name,
  218. unit,
  219. color: colorFn(colors[i])
  220. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  221. .string(),
  222. hidden: focusedSeries && focusedSeries !== item.name,
  223. data: item.values.map((value, index) => ({
  224. name: moment(data.intervals[index]).valueOf(),
  225. value,
  226. })),
  227. transaction: item.transaction as string | undefined,
  228. release: item.release as string | undefined,
  229. emphasis: {
  230. focus: 'series',
  231. } as LineSeriesOption['emphasis'],
  232. })) as Series[];
  233. }
  234. function sortSeries(
  235. series: {
  236. name: string;
  237. release: string;
  238. transaction: string;
  239. values: (number | null)[];
  240. }[],
  241. displayType: MetricDisplayType
  242. ) {
  243. const sorted = series
  244. // we need to sort the series by their values so that the colors in area chart do not overlap
  245. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  246. .sort((a, b) => {
  247. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  248. });
  249. if (displayType === MetricDisplayType.BAR) {
  250. return sorted.toReversed();
  251. }
  252. return sorted;
  253. }
  254. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  255. const palette = theme.charts.getColorPalette(length - 2);
  256. if (displayType === MetricDisplayType.BAR) {
  257. return palette;
  258. }
  259. return palette.toReversed();
  260. }
  261. function normalizeChartTimeParams(data: MetricsApiResponse) {
  262. const {
  263. start,
  264. end,
  265. utc: utcString,
  266. statsPeriod,
  267. } = normalizeDateTimeParams(data, {
  268. allowEmptyPeriod: true,
  269. allowAbsoluteDatetime: true,
  270. allowAbsolutePageDatetime: true,
  271. });
  272. const utc = utcString === 'true';
  273. if (start && end) {
  274. return utc
  275. ? {
  276. start: moment.utc(start).format(),
  277. end: moment.utc(end).format(),
  278. utc,
  279. }
  280. : {
  281. start: moment(start).utc().format(),
  282. end: moment(end).utc().format(),
  283. utc,
  284. };
  285. }
  286. return {
  287. period: statsPeriod ?? '90d',
  288. };
  289. }
  290. export type Series = {
  291. color: string;
  292. data: {name: number; value: number}[];
  293. seriesName: string;
  294. unit: string;
  295. hidden?: boolean;
  296. release?: string;
  297. transaction?: string;
  298. };
  299. const MetricWidgetPanel = styled(Panel)<{isSelected: boolean}>`
  300. padding-bottom: 0;
  301. margin-bottom: 0;
  302. min-width: ${MIN_WIDGET_WIDTH}px;
  303. position: relative;
  304. ${p =>
  305. p.isSelected &&
  306. // Use ::after to avoid layout shifts when the border changes from 1px to 2px
  307. `
  308. &::after {
  309. content: '';
  310. position: absolute;
  311. top: -1px;
  312. left: -1px;
  313. bottom: -1px;
  314. right: -1px;
  315. pointer-events: none;
  316. border: 2px solid ${p.theme.purple300};
  317. border-radius: ${p.theme.borderRadius};
  318. `}
  319. `;
  320. const StyledMetricWidgetBody = styled('div')`
  321. padding: ${space(1)};
  322. display: flex;
  323. flex-direction: column;
  324. justify-content: center;
  325. `;