widget.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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, MRI, 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 {MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
  26. import type {
  27. FocusedMetricsSeries,
  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 {useMetricSamples} from 'sentry/utils/metrics/useMetricsCorrelations';
  34. import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
  35. import {MetricChart} from 'sentry/views/ddm/chart';
  36. import type {FocusAreaProps} from 'sentry/views/ddm/context';
  37. import {createChartPalette} from 'sentry/views/ddm/metricsChartPalette';
  38. import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
  39. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  40. import {getQueryWithFocusedSeries} from 'sentry/views/ddm/utils';
  41. import {DDM_CHART_GROUP, MIN_WIDGET_WIDTH} from './constants';
  42. type MetricWidgetProps = {
  43. datetime: PageFilters['datetime'];
  44. environments: PageFilters['environments'];
  45. onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  46. projects: PageFilters['projects'];
  47. widget: MetricWidgetQueryParams;
  48. chartHeight?: number;
  49. focusArea?: FocusAreaProps;
  50. getChartPalette?: (seriesNames: string[]) => Record<string, string>;
  51. hasSiblings?: boolean;
  52. highlightedSampleId?: string;
  53. index?: number;
  54. isSelected?: boolean;
  55. onSampleClick?: (sample: Sample) => void;
  56. onSelect?: (index: number) => void;
  57. showQuerySymbols?: boolean;
  58. };
  59. export type Sample = {
  60. projectId: number;
  61. spanId: string;
  62. transactionId: string;
  63. transactionSpanId: string;
  64. };
  65. export const MetricWidget = memo(
  66. ({
  67. widget,
  68. datetime,
  69. projects,
  70. environments,
  71. index = 0,
  72. isSelected = false,
  73. getChartPalette,
  74. onSelect,
  75. onChange,
  76. hasSiblings = false,
  77. showQuerySymbols,
  78. focusArea,
  79. onSampleClick,
  80. highlightedSampleId,
  81. chartHeight = 300,
  82. }: MetricWidgetProps) => {
  83. const handleChange = useCallback(
  84. (data: Partial<MetricWidgetQueryParams>) => {
  85. onChange(index, data);
  86. },
  87. [index, onChange]
  88. );
  89. const metricsQuery = useMemo(
  90. () => ({
  91. mri: widget.mri,
  92. query: widget.query,
  93. op: widget.op,
  94. groupBy: widget.groupBy,
  95. projects,
  96. datetime,
  97. environments,
  98. }),
  99. [
  100. widget.mri,
  101. widget.query,
  102. widget.op,
  103. widget.groupBy,
  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 queryWithFocusedSeries = useMemo(
  121. () => getQueryWithFocusedSeries(widget),
  122. [widget]
  123. );
  124. const samplesQuery = useMetricSamples(metricsQuery.mri, {
  125. ...focusArea?.selection?.range,
  126. query: queryWithFocusedSeries,
  127. });
  128. const samples = useMemo(() => {
  129. return {
  130. data: samplesQuery.data,
  131. onClick: onSampleClick,
  132. higlightedId: highlightedSampleId,
  133. };
  134. }, [samplesQuery.data, onSampleClick, highlightedSampleId]);
  135. const widgetTitle = stringifyMetricWidget(metricsQuery);
  136. return (
  137. <MetricWidgetPanel
  138. // show the selection border only if we have more widgets than one
  139. isHighlighted={isSelected && !!hasSiblings}
  140. isHighlightable={!!hasSiblings}
  141. onClick={() => onSelect?.(index)}
  142. >
  143. <PanelBody>
  144. <MetricWidgetHeader>
  145. {showQuerySymbols && <QuerySymbol index={index} isSelected={isSelected} />}
  146. <WidgetTitle>
  147. <StyledTooltip
  148. title={widgetTitle}
  149. showOnlyOnOverflow
  150. delay={500}
  151. overlayStyle={{maxWidth: '90vw'}}
  152. >
  153. {widgetTitle}
  154. </StyledTooltip>
  155. </WidgetTitle>
  156. <CompactSelect
  157. size="xs"
  158. triggerProps={{prefix: t('Display')}}
  159. value={
  160. widget.displayType ??
  161. getDefaultMetricDisplayType(metricsQuery.mri, metricsQuery.op)
  162. }
  163. options={metricDisplayTypeOptions}
  164. onChange={handleDisplayTypeChange}
  165. />
  166. </MetricWidgetHeader>
  167. <MetricWidgetBodyWrapper>
  168. {widget.mri ? (
  169. <MetricWidgetBody
  170. widgetIndex={index}
  171. datetime={datetime}
  172. projects={projects}
  173. getChartPalette={getChartPalette}
  174. environments={environments}
  175. onChange={handleChange}
  176. focusArea={focusArea}
  177. samples={samples}
  178. chartHeight={chartHeight}
  179. chartGroup={DDM_CHART_GROUP}
  180. {...widget}
  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 extends MetricWidgetQueryParams {
  198. widgetIndex: number;
  199. chartGroup?: string;
  200. chartHeight?: number;
  201. focusArea?: FocusAreaProps;
  202. getChartPalette?: (seriesNames: string[]) => Record<string, string>;
  203. onChange?: (data: Partial<MetricWidgetQueryParams>) => void;
  204. samples?: SamplesProps;
  205. }
  206. export interface SamplesProps {
  207. data?: MetricCorrelation[];
  208. higlightedId?: string;
  209. onClick?: (sample: Sample) => void;
  210. }
  211. const MetricWidgetBody = memo(
  212. ({
  213. onChange,
  214. displayType,
  215. focusedSeries,
  216. sort,
  217. widgetIndex,
  218. getChartPalette = createChartPalette,
  219. focusArea,
  220. chartHeight,
  221. chartGroup,
  222. samples,
  223. ...metricsQuery
  224. }: MetricWidgetBodyProps & PageFilters) => {
  225. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  226. const {
  227. data: timeseriesData,
  228. isLoading,
  229. isError,
  230. error,
  231. } = useMetricsQuery(
  232. [{mri, op, query, groupBy}],
  233. {
  234. projects,
  235. environments,
  236. datetime,
  237. },
  238. {intervalLadder: displayType === MetricDisplayType.BAR ? 'bar' : 'ddm'}
  239. );
  240. const chartRef = useRef<ReactEchartsRef>(null);
  241. const setHoveredSeries = useCallback((legend: string) => {
  242. if (!chartRef.current) {
  243. return;
  244. }
  245. const echartsInstance = chartRef.current.getEchartsInstance();
  246. echartsInstance.dispatchAction({
  247. type: 'highlight',
  248. seriesName: legend,
  249. });
  250. }, []);
  251. const chartSeries = useMemo(() => {
  252. return timeseriesData
  253. ? getChartTimeseries(timeseriesData, {
  254. field: MRIToField(mri, op || ''),
  255. getChartPalette,
  256. mri,
  257. focusedSeries:
  258. focusedSeries && new Set(focusedSeries?.map(s => s.seriesName)),
  259. })
  260. : [];
  261. }, [timeseriesData, op, mri, 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={metricsQuery.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={sort}
  355. operation={metricsQuery.op}
  356. onRowClick={setSeriesVisibility}
  357. onColorDotClick={toggleSeriesVisibility}
  358. setHoveredSeries={setHoveredSeries}
  359. />
  360. </StyledMetricWidgetBody>
  361. );
  362. }
  363. );
  364. export function getChartTimeseries(
  365. data: MetricsQueryApiResponse,
  366. {
  367. getChartPalette,
  368. field,
  369. mri,
  370. focusedSeries,
  371. }: {
  372. field: string;
  373. getChartPalette: (seriesNames: string[]) => Record<string, string>;
  374. mri: MRI;
  375. focusedSeries?: Set<string>;
  376. }
  377. ) {
  378. // this assumes that all series have the same unit
  379. const parsed = parseMRI(mri);
  380. const unit = parsed?.unit ?? '';
  381. const series = data.data.flatMap(group =>
  382. group.map(entry => ({
  383. values: entry.series,
  384. name: getMetricsSeriesName(field, entry.by),
  385. groupBy: entry.by,
  386. transaction: entry.by.transaction,
  387. release: entry.by.release,
  388. }))
  389. );
  390. const chartPalette = getChartPalette(series.map(s => s.name));
  391. return series.map(item => ({
  392. seriesName: item.name,
  393. groupBy: item.groupBy,
  394. unit,
  395. color: chartPalette[item.name],
  396. hidden: focusedSeries && focusedSeries.size > 0 && !focusedSeries.has(item.name),
  397. data: item.values.map((value, index) => ({
  398. name: moment(data.intervals[index]).valueOf(),
  399. value,
  400. })),
  401. transaction: item.transaction as string | undefined,
  402. release: item.release as string | undefined,
  403. emphasis: {
  404. focus: 'series',
  405. } as SeriesOption['emphasis'],
  406. })) as Series[];
  407. }
  408. export type Series = {
  409. color: string;
  410. data: {name: number; value: number}[];
  411. seriesName: string;
  412. unit: string;
  413. groupBy?: Record<string, string>;
  414. hidden?: boolean;
  415. release?: string;
  416. transaction?: string;
  417. };
  418. export interface ScatterSeries extends Series {
  419. itemStyle: {
  420. color: string;
  421. opacity: number;
  422. };
  423. projectId: number;
  424. spanId: string;
  425. symbol: string;
  426. symbolSize: number;
  427. transactionId: string;
  428. z: number;
  429. }
  430. const MetricWidgetPanel = styled(Panel)<{
  431. isHighlightable: boolean;
  432. isHighlighted: boolean;
  433. }>`
  434. padding-bottom: 0;
  435. margin-bottom: 0;
  436. min-width: ${MIN_WIDGET_WIDTH}px;
  437. position: relative;
  438. transition: box-shadow 0.2s ease;
  439. ${p =>
  440. p.isHighlightable &&
  441. `
  442. &:focus,
  443. &:hover {
  444. box-shadow: 0px 0px 0px 3px
  445. ${p.isHighlighted ? p.theme.purple200 : 'rgba(209, 202, 216, 0.2)'};
  446. }
  447. `}
  448. ${p =>
  449. p.isHighlighted &&
  450. `
  451. box-shadow: 0px 0px 0px 3px ${p.theme.purple200};
  452. border-color: transparent;
  453. `}
  454. `;
  455. const StyledMetricWidgetBody = styled('div')`
  456. padding: ${space(1)};
  457. gap: ${space(3)};
  458. display: flex;
  459. flex-direction: column;
  460. justify-content: center;
  461. height: 100%;
  462. `;
  463. const MetricWidgetBodyWrapper = styled('div')`
  464. padding: ${space(1)};
  465. padding-bottom: 0;
  466. `;
  467. const MetricWidgetHeader = styled('div')`
  468. display: flex;
  469. justify-content: space-between;
  470. align-items: center;
  471. gap: ${space(1)};
  472. padding-left: ${space(2)};
  473. padding-top: ${space(1.5)};
  474. padding-right: ${space(2)};
  475. `;
  476. const WidgetTitle = styled('div')`
  477. flex-grow: 1;
  478. font-size: ${p => p.theme.fontSizeMedium};
  479. display: inline-grid;
  480. grid-auto-flow: column;
  481. `;
  482. const StyledTooltip = styled(Tooltip)`
  483. ${p => p.theme.overflowEllipsis};
  484. `;