widget.tsx 13 KB

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