widget.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import {memo, useCallback, useMemo, useRef} 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 {CompactSelect, SelectOption} from 'sentry/components/compactSelect';
  9. import EmptyMessage from 'sentry/components/emptyMessage';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelBody from 'sentry/components/panels/panelBody';
  13. import {Tooltip} from 'sentry/components/tooltip';
  14. import {IconSearch} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import {MetricsApiResponse, MRI, PageFilters} from 'sentry/types';
  18. import {ReactEchartsRef} from 'sentry/types/echarts';
  19. import {
  20. getDefaultMetricDisplayType,
  21. getSeriesName,
  22. MetricDisplayType,
  23. metricDisplayTypeOptions,
  24. MetricWidgetQueryParams,
  25. stringifyMetricWidget,
  26. } from 'sentry/utils/metrics';
  27. import {parseMRI} from 'sentry/utils/metrics/mri';
  28. import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
  29. import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  30. import theme from 'sentry/utils/theme';
  31. import {MetricChart} from 'sentry/views/ddm/chart';
  32. import {FocusArea} from 'sentry/views/ddm/chartBrush';
  33. import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
  34. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  35. import {MIN_WIDGET_WIDTH} from './constants';
  36. export const MetricWidget = memo(
  37. ({
  38. widget,
  39. datetime,
  40. projects,
  41. environments,
  42. index,
  43. isSelected,
  44. onSelect,
  45. onChange,
  46. hasSiblings,
  47. addFocusArea,
  48. removeFocusArea,
  49. focusArea,
  50. }: {
  51. addFocusArea: (area: FocusArea) => void;
  52. datetime: PageFilters['datetime'];
  53. environments: PageFilters['environments'];
  54. focusArea: FocusArea | null;
  55. hasSiblings: boolean;
  56. index: number;
  57. isSelected: boolean;
  58. onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  59. onSelect: (index: number) => void;
  60. projects: PageFilters['projects'];
  61. removeFocusArea: () => void;
  62. widget: MetricWidgetQueryParams;
  63. }) => {
  64. const handleChange = useCallback(
  65. (data: Partial<MetricWidgetQueryParams>) => {
  66. onChange(index, data);
  67. },
  68. [index, onChange]
  69. );
  70. const metricsQuery = useMemo(
  71. () => ({
  72. mri: widget.mri,
  73. query: widget.query,
  74. op: widget.op,
  75. groupBy: widget.groupBy,
  76. projects,
  77. datetime,
  78. environments,
  79. title: widget.title,
  80. }),
  81. [
  82. widget.mri,
  83. widget.query,
  84. widget.op,
  85. widget.groupBy,
  86. widget.title,
  87. projects,
  88. datetime,
  89. environments,
  90. ]
  91. );
  92. const incrementQueryMetric = useIncrementQueryMetric({
  93. displayType: widget.displayType,
  94. op: metricsQuery.op,
  95. groupBy: metricsQuery.groupBy,
  96. query: metricsQuery.query,
  97. mri: metricsQuery.mri,
  98. });
  99. const handleDisplayTypeChange = ({value}: SelectOption<MetricDisplayType>) => {
  100. incrementQueryMetric('ddm.widget.display', {displayType: value});
  101. onChange(index, {displayType: value});
  102. };
  103. const widgetTitle = stringifyMetricWidget(metricsQuery);
  104. return (
  105. <MetricWidgetPanel
  106. // show the selection border only if we have more widgets than one
  107. isHighlighted={isSelected && !!hasSiblings}
  108. isHighlightable={!!hasSiblings}
  109. onClick={() => onSelect(index)}
  110. >
  111. <PanelBody>
  112. <MetricWidgetHeader>
  113. <QuerySymbol index={index} />
  114. <WidgetTitle>
  115. <StyledTooltip
  116. title={widgetTitle}
  117. showOnlyOnOverflow
  118. delay={500}
  119. overlayStyle={{maxWidth: '90vw'}}
  120. >
  121. {widgetTitle}
  122. </StyledTooltip>
  123. </WidgetTitle>
  124. <CompactSelect
  125. size="xs"
  126. triggerProps={{prefix: t('Display')}}
  127. value={
  128. widget.displayType ??
  129. getDefaultMetricDisplayType(metricsQuery.mri, metricsQuery.op)
  130. }
  131. options={metricDisplayTypeOptions}
  132. onChange={handleDisplayTypeChange}
  133. />
  134. </MetricWidgetHeader>
  135. {widget.mri ? (
  136. <MetricWidgetBody
  137. widgetIndex={index}
  138. datetime={datetime}
  139. projects={projects}
  140. environments={environments}
  141. onChange={handleChange}
  142. addFocusArea={addFocusArea}
  143. focusArea={focusArea}
  144. removeFocusArea={removeFocusArea}
  145. {...widget}
  146. />
  147. ) : (
  148. <StyledMetricWidgetBody>
  149. <EmptyMessage
  150. icon={<IconSearch size="xxl" />}
  151. title={t('Nothing to show!')}
  152. description={t('Choose a metric to display data.')}
  153. />
  154. </StyledMetricWidgetBody>
  155. )}
  156. </PanelBody>
  157. </MetricWidgetPanel>
  158. );
  159. }
  160. );
  161. interface MetricWidgetProps extends MetricWidgetQueryParams {
  162. addFocusArea: (area: FocusArea) => void;
  163. focusArea: FocusArea | null;
  164. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  165. removeFocusArea: () => void;
  166. widgetIndex: number;
  167. }
  168. const MetricWidgetBody = memo(
  169. ({
  170. onChange,
  171. displayType,
  172. focusedSeries,
  173. sort,
  174. widgetIndex,
  175. addFocusArea,
  176. focusArea,
  177. removeFocusArea,
  178. ...metricsQuery
  179. }: MetricWidgetProps & PageFilters) => {
  180. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  181. const {data, isLoading, isError, error} = useMetricsDataZoom(
  182. {
  183. mri,
  184. op,
  185. query,
  186. groupBy,
  187. projects,
  188. environments,
  189. datetime,
  190. },
  191. {fidelity: displayType === MetricDisplayType.BAR ? 'low' : 'high'}
  192. );
  193. const chartRef = useRef<ReactEchartsRef>(null);
  194. const setHoveredSeries = useCallback((legend: string) => {
  195. if (!chartRef.current) {
  196. return;
  197. }
  198. const echartsInstance = chartRef.current.getEchartsInstance();
  199. echartsInstance.dispatchAction({
  200. type: 'highlight',
  201. seriesName: legend,
  202. });
  203. }, []);
  204. const toggleSeriesVisibility = useCallback(
  205. (seriesName: string) => {
  206. setHoveredSeries('');
  207. onChange({
  208. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  209. });
  210. },
  211. [focusedSeries, onChange, setHoveredSeries]
  212. );
  213. const chartSeries = useMemo(() => {
  214. return (
  215. data &&
  216. getChartSeries(data, {
  217. mri,
  218. focusedSeries,
  219. groupBy: metricsQuery.groupBy,
  220. displayType,
  221. })
  222. );
  223. }, [data, displayType, focusedSeries, metricsQuery.groupBy, mri]);
  224. const handleSortChange = useCallback(
  225. newSort => {
  226. onChange({sort: newSort});
  227. },
  228. [onChange]
  229. );
  230. if (!chartSeries || !data || isError) {
  231. return (
  232. <StyledMetricWidgetBody>
  233. {isLoading && <LoadingIndicator />}
  234. {isError && (
  235. <Alert type="error">
  236. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  237. </Alert>
  238. )}
  239. </StyledMetricWidgetBody>
  240. );
  241. }
  242. if (data.groups.length === 0) {
  243. return (
  244. <StyledMetricWidgetBody>
  245. <EmptyMessage
  246. icon={<IconSearch size="xxl" />}
  247. title={t('No results')}
  248. description={t('No results found for the given query')}
  249. />
  250. </StyledMetricWidgetBody>
  251. );
  252. }
  253. return (
  254. <StyledMetricWidgetBody>
  255. <TransparentLoadingMask visible={isLoading} />
  256. <MetricChart
  257. ref={chartRef}
  258. series={chartSeries}
  259. displayType={displayType}
  260. operation={metricsQuery.op}
  261. widgetIndex={widgetIndex}
  262. addFocusArea={addFocusArea}
  263. focusArea={focusArea}
  264. removeFocusArea={removeFocusArea}
  265. />
  266. {metricsQuery.showSummaryTable && (
  267. <SummaryTable
  268. series={chartSeries}
  269. onSortChange={handleSortChange}
  270. sort={sort}
  271. operation={metricsQuery.op}
  272. onRowClick={toggleSeriesVisibility}
  273. setHoveredSeries={focusedSeries ? undefined : setHoveredSeries}
  274. />
  275. )}
  276. </StyledMetricWidgetBody>
  277. );
  278. }
  279. );
  280. export function getChartSeries(
  281. data: MetricsApiResponse,
  282. {
  283. mri,
  284. focusedSeries,
  285. groupBy,
  286. hoveredLegend,
  287. displayType,
  288. }: {
  289. displayType: MetricDisplayType;
  290. mri: MRI;
  291. focusedSeries?: string;
  292. groupBy?: string[];
  293. hoveredLegend?: string;
  294. }
  295. ) {
  296. // this assumes that all series have the same unit
  297. const parsed = parseMRI(mri);
  298. const unit = parsed?.unit ?? '';
  299. const series = data.groups.map(g => {
  300. return {
  301. values: Object.values(g.series)[0],
  302. name: getSeriesName(g, data.groups.length === 1, groupBy),
  303. transaction: g.by.transaction,
  304. release: g.by.release,
  305. };
  306. });
  307. const colors = getChartColorPalette(displayType, series.length);
  308. return sortSeries(series, displayType).map((item, i) => ({
  309. seriesName: item.name,
  310. unit,
  311. color: colorFn(colors[i % colors.length])
  312. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  313. .string(),
  314. hidden: focusedSeries && focusedSeries !== item.name,
  315. data: item.values.map((value, index) => ({
  316. name: moment(data.intervals[index]).valueOf(),
  317. value,
  318. })),
  319. transaction: item.transaction as string | undefined,
  320. release: item.release as string | undefined,
  321. emphasis: {
  322. focus: 'series',
  323. } as LineSeriesOption['emphasis'],
  324. })) as Series[];
  325. }
  326. function sortSeries(
  327. series: {
  328. name: string;
  329. release: string;
  330. transaction: string;
  331. values: (number | null)[];
  332. }[],
  333. displayType: MetricDisplayType
  334. ) {
  335. const sorted = series
  336. // we need to sort the series by their values so that the colors in area chart do not overlap
  337. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  338. .sort((a, b) => {
  339. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  340. });
  341. if (displayType === MetricDisplayType.BAR) {
  342. return sorted.toReversed();
  343. }
  344. return sorted;
  345. }
  346. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  347. // We do length - 2 to be aligned with the colors in other parts of the app (copy-pasta)
  348. // We use Math.max to avoid numbers < -1 as then `getColorPalette` returns undefined (not typesafe because of array access)
  349. const palette = theme.charts.getColorPalette(Math.max(length - 2, -1));
  350. if (displayType === MetricDisplayType.BAR) {
  351. return palette;
  352. }
  353. return palette.toReversed();
  354. }
  355. export type Series = {
  356. color: string;
  357. data: {name: number; value: number}[];
  358. seriesName: string;
  359. unit: string;
  360. hidden?: boolean;
  361. release?: string;
  362. transaction?: string;
  363. };
  364. const MetricWidgetPanel = styled(Panel)<{
  365. isHighlightable: boolean;
  366. isHighlighted: boolean;
  367. }>`
  368. padding-bottom: 0;
  369. margin-bottom: 0;
  370. min-width: ${MIN_WIDGET_WIDTH}px;
  371. position: relative;
  372. transition: box-shadow 0.2s ease;
  373. ${p =>
  374. p.isHighlightable &&
  375. `
  376. &:focus,
  377. &:hover {
  378. box-shadow: 0px 0px 0px 3px
  379. ${p.isHighlighted ? p.theme.purple200 : 'rgba(209, 202, 216, 0.2)'};
  380. }
  381. `}
  382. ${p =>
  383. p.isHighlighted &&
  384. `
  385. box-shadow: 0px 0px 0px 3px ${p.theme.purple200};
  386. border-color: transparent;
  387. `}
  388. `;
  389. const StyledMetricWidgetBody = styled('div')`
  390. padding: ${space(1)};
  391. display: flex;
  392. flex-direction: column;
  393. justify-content: center;
  394. `;
  395. const MetricWidgetHeader = styled('div')`
  396. display: flex;
  397. justify-content: space-between;
  398. align-items: center;
  399. gap: ${space(1)};
  400. padding-left: ${space(2)};
  401. padding-top: ${space(1.5)};
  402. padding-right: ${space(2)};
  403. `;
  404. const WidgetTitle = styled('div')`
  405. flex-grow: 1;
  406. font-size: ${p => p.theme.fontSizeMedium};
  407. display: inline-grid;
  408. grid-auto-flow: column;
  409. `;
  410. const StyledTooltip = styled(Tooltip)`
  411. ${p => p.theme.overflowEllipsis};
  412. `;