widget.tsx 14 KB

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