widget.tsx 14 KB

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