widget.tsx 15 KB

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