widget.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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 {MetricsApiResponse, 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 {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 {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  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. focusArea?: FocusAreaProps;
  49. getChartPalette?: (seriesNames: string[]) => Record<string, string>;
  50. hasSiblings?: boolean;
  51. highlightedSampleId?: string;
  52. index?: number;
  53. isSelected?: boolean;
  54. onSampleClick?: (sample: Sample) => void;
  55. onSelect?: (index: number) => void;
  56. showQuerySymbols?: boolean;
  57. };
  58. export type Sample = {
  59. projectId: number;
  60. spanId: string;
  61. transactionId: string;
  62. transactionSpanId: string;
  63. };
  64. export const MetricWidget = memo(
  65. ({
  66. widget,
  67. datetime,
  68. projects,
  69. environments,
  70. index = 0,
  71. isSelected = false,
  72. getChartPalette,
  73. onSelect,
  74. onChange,
  75. hasSiblings = false,
  76. showQuerySymbols,
  77. focusArea,
  78. onSampleClick,
  79. highlightedSampleId,
  80. }: MetricWidgetProps) => {
  81. const handleChange = useCallback(
  82. (data: Partial<MetricWidgetQueryParams>) => {
  83. onChange(index, data);
  84. },
  85. [index, onChange]
  86. );
  87. const metricsQuery = useMemo(
  88. () => ({
  89. mri: widget.mri,
  90. query: widget.query,
  91. op: widget.op,
  92. groupBy: widget.groupBy,
  93. projects,
  94. datetime,
  95. environments,
  96. title: widget.title,
  97. }),
  98. [
  99. widget.mri,
  100. widget.query,
  101. widget.op,
  102. widget.groupBy,
  103. widget.title,
  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 = metricsQuery.title ?? 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={300}
  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. } = useMetricsDataZoom(
  232. {
  233. mri,
  234. op,
  235. query,
  236. groupBy,
  237. projects,
  238. environments,
  239. datetime,
  240. },
  241. {intervalLadder: displayType === MetricDisplayType.BAR ? 'bar' : 'ddm'}
  242. );
  243. const chartRef = useRef<ReactEchartsRef>(null);
  244. const setHoveredSeries = useCallback((legend: string) => {
  245. if (!chartRef.current) {
  246. return;
  247. }
  248. const echartsInstance = chartRef.current.getEchartsInstance();
  249. echartsInstance.dispatchAction({
  250. type: 'highlight',
  251. seriesName: legend,
  252. });
  253. }, []);
  254. const chartSeries = useMemo(() => {
  255. return timeseriesData
  256. ? getChartTimeseries(timeseriesData, {
  257. getChartPalette,
  258. mri,
  259. focusedSeries:
  260. focusedSeries && new Set(focusedSeries?.map(s => s.seriesName)),
  261. })
  262. : [];
  263. }, [timeseriesData, getChartPalette, mri, focusedSeries]);
  264. const toggleSeriesVisibility = useCallback(
  265. (series: FocusedMetricsSeries) => {
  266. setHoveredSeries('');
  267. // The focused series array is not populated yet, so we can add all series except the one that was de-selected
  268. if (!focusedSeries || focusedSeries.length === 0) {
  269. onChange?.({
  270. focusedSeries: chartSeries
  271. .filter(s => s.seriesName !== series.seriesName)
  272. .map(s => ({
  273. seriesName: s.seriesName,
  274. groupBy: s.groupBy,
  275. })),
  276. });
  277. return;
  278. }
  279. const filteredSeries = focusedSeries.filter(
  280. s => s.seriesName !== series.seriesName
  281. );
  282. if (filteredSeries.length === focusedSeries.length) {
  283. // The series was not focused before so we can add it
  284. filteredSeries.push(series);
  285. }
  286. onChange?.({
  287. focusedSeries: filteredSeries,
  288. });
  289. },
  290. [chartSeries, focusedSeries, onChange, setHoveredSeries]
  291. );
  292. const setSeriesVisibility = useCallback(
  293. (series: FocusedMetricsSeries) => {
  294. setHoveredSeries('');
  295. if (
  296. focusedSeries?.length === 1 &&
  297. focusedSeries[0].seriesName === series.seriesName
  298. ) {
  299. onChange?.({
  300. focusedSeries: [],
  301. });
  302. return;
  303. }
  304. onChange?.({
  305. focusedSeries: [series],
  306. });
  307. },
  308. [focusedSeries, onChange, setHoveredSeries]
  309. );
  310. const handleSortChange = useCallback(
  311. newSort => {
  312. onChange?.({sort: newSort});
  313. },
  314. [onChange]
  315. );
  316. if (!chartSeries || !timeseriesData || isError) {
  317. return (
  318. <StyledMetricWidgetBody>
  319. {isLoading && <LoadingIndicator />}
  320. {isError && (
  321. <Alert type="error">
  322. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  323. </Alert>
  324. )}
  325. </StyledMetricWidgetBody>
  326. );
  327. }
  328. if (timeseriesData.groups.length === 0) {
  329. return (
  330. <StyledMetricWidgetBody>
  331. <EmptyMessage
  332. icon={<IconSearch size="xxl" />}
  333. title={t('No results')}
  334. description={t('No results found for the given query')}
  335. />
  336. </StyledMetricWidgetBody>
  337. );
  338. }
  339. return (
  340. <StyledMetricWidgetBody>
  341. <TransparentLoadingMask visible={isLoading} />
  342. <MetricChart
  343. ref={chartRef}
  344. series={chartSeries}
  345. displayType={displayType}
  346. operation={metricsQuery.op}
  347. widgetIndex={widgetIndex}
  348. height={chartHeight}
  349. scatter={samples}
  350. focusArea={focusArea}
  351. group={chartGroup}
  352. />
  353. {metricsQuery.showSummaryTable && (
  354. <SummaryTable
  355. series={chartSeries}
  356. onSortChange={handleSortChange}
  357. sort={sort}
  358. operation={metricsQuery.op}
  359. onRowClick={setSeriesVisibility}
  360. onColorDotClick={toggleSeriesVisibility}
  361. setHoveredSeries={setHoveredSeries}
  362. />
  363. )}
  364. </StyledMetricWidgetBody>
  365. );
  366. }
  367. );
  368. export function getChartTimeseries(
  369. data: MetricsApiResponse,
  370. {
  371. getChartPalette,
  372. mri,
  373. focusedSeries,
  374. }: {
  375. getChartPalette: (seriesNames: string[]) => Record<string, string>;
  376. mri: MRI;
  377. focusedSeries?: Set<string>;
  378. }
  379. ) {
  380. // this assumes that all series have the same unit
  381. const parsed = parseMRI(mri);
  382. const unit = parsed?.unit ?? '';
  383. const series = data.groups.map(g => {
  384. return {
  385. values: Object.values(g.series)[0],
  386. name: getMetricsSeriesName(g),
  387. groupBy: g.by,
  388. transaction: g.by.transaction,
  389. release: g.by.release,
  390. };
  391. });
  392. const chartPalette = getChartPalette(series.map(s => s.name));
  393. return series.map(item => ({
  394. seriesName: item.name,
  395. groupBy: item.groupBy,
  396. unit,
  397. color: chartPalette[item.name],
  398. hidden: focusedSeries && focusedSeries.size > 0 && !focusedSeries.has(item.name),
  399. data: item.values.map((value, index) => ({
  400. name: moment(data.intervals[index]).valueOf(),
  401. value,
  402. })),
  403. transaction: item.transaction as string | undefined,
  404. release: item.release as string | undefined,
  405. emphasis: {
  406. focus: 'series',
  407. } as SeriesOption['emphasis'],
  408. })) as Series[];
  409. }
  410. export type Series = {
  411. color: string;
  412. data: {name: number; value: number}[];
  413. seriesName: string;
  414. unit: string;
  415. groupBy?: Record<string, string>;
  416. hidden?: boolean;
  417. release?: string;
  418. transaction?: string;
  419. };
  420. export interface ScatterSeries extends Series {
  421. itemStyle: {
  422. color: string;
  423. opacity: number;
  424. };
  425. projectId: number;
  426. spanId: string;
  427. symbol: string;
  428. symbolSize: number;
  429. transactionId: string;
  430. z: number;
  431. }
  432. const MetricWidgetPanel = styled(Panel)<{
  433. isHighlightable: boolean;
  434. isHighlighted: boolean;
  435. }>`
  436. padding-bottom: 0;
  437. margin-bottom: 0;
  438. min-width: ${MIN_WIDGET_WIDTH}px;
  439. position: relative;
  440. transition: box-shadow 0.2s ease;
  441. ${p =>
  442. p.isHighlightable &&
  443. `
  444. &:focus,
  445. &:hover {
  446. box-shadow: 0px 0px 0px 3px
  447. ${p.isHighlighted ? p.theme.purple200 : 'rgba(209, 202, 216, 0.2)'};
  448. }
  449. `}
  450. ${p =>
  451. p.isHighlighted &&
  452. `
  453. box-shadow: 0px 0px 0px 3px ${p.theme.purple200};
  454. border-color: transparent;
  455. `}
  456. `;
  457. const StyledMetricWidgetBody = styled('div')`
  458. padding: ${space(1)};
  459. gap: ${space(3)};
  460. display: flex;
  461. flex-direction: column;
  462. justify-content: center;
  463. height: 100%;
  464. `;
  465. const MetricWidgetBodyWrapper = styled('div')`
  466. padding: ${space(1)};
  467. padding-bottom: 0;
  468. `;
  469. const MetricWidgetHeader = styled('div')`
  470. display: flex;
  471. justify-content: space-between;
  472. align-items: center;
  473. gap: ${space(1)};
  474. padding-left: ${space(2)};
  475. padding-top: ${space(1.5)};
  476. padding-right: ${space(2)};
  477. `;
  478. const WidgetTitle = styled('div')`
  479. flex-grow: 1;
  480. font-size: ${p => p.theme.fontSizeMedium};
  481. display: inline-grid;
  482. grid-auto-flow: column;
  483. `;
  484. const StyledTooltip = styled(Tooltip)`
  485. ${p => p.theme.overflowEllipsis};
  486. `;