widget.tsx 15 KB

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