widget.tsx 14 KB

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