widget.tsx 14 KB

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