scratchpad.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import {useCallback, useLayoutEffect, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as echarts from 'echarts/core';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import type {Field} from 'sentry/components/ddm/metricSamplesTable';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import {getMetricsCorrelationSpanUrl, unescapeMetricsFormula} from 'sentry/utils/metrics';
  9. import {
  10. MetricQueryType,
  11. type MetricQueryWidgetParams,
  12. type MetricWidgetQueryParams,
  13. } from 'sentry/utils/metrics/types';
  14. import type {MetricsQueryApiQueryParams} from 'sentry/utils/metrics/useMetricsQuery';
  15. import type {MetricsSamplesResults} from 'sentry/utils/metrics/useMetricsSamples';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import usePageFilters from 'sentry/utils/usePageFilters';
  18. import useRouter from 'sentry/utils/useRouter';
  19. import {DDM_CHART_GROUP, MIN_WIDGET_WIDTH} from 'sentry/views/ddm/constants';
  20. import {useDDMContext} from 'sentry/views/ddm/context';
  21. import {getEquationSymbol} from 'sentry/views/ddm/equationSymbol copy';
  22. import {parseFormula} from 'sentry/views/ddm/formulaParser/parser';
  23. import {type TokenList, TokenType} from 'sentry/views/ddm/formulaParser/types';
  24. import {getQuerySymbol} from 'sentry/views/ddm/querySymbol';
  25. import {useGetCachedChartPalette} from 'sentry/views/ddm/utils/metricsChartPalette';
  26. import {MetricWidget} from './widget';
  27. interface WidgetDependencies {
  28. dependencies: MetricsQueryApiQueryParams[];
  29. isError: boolean;
  30. }
  31. function widgetToQuery(
  32. widget: MetricWidgetQueryParams,
  33. isQueryOnly = false
  34. ): MetricsQueryApiQueryParams {
  35. return widget.type === MetricQueryType.FORMULA
  36. ? {
  37. name: getEquationSymbol(widget.id),
  38. formula: widget.formula,
  39. }
  40. : {
  41. name: getQuerySymbol(widget.id),
  42. mri: widget.mri,
  43. op: widget.op,
  44. groupBy: widget.groupBy,
  45. query: widget.query,
  46. isQueryOnly: isQueryOnly || widget.isHidden,
  47. };
  48. }
  49. export function MetricScratchpad() {
  50. const {
  51. setSelectedWidgetIndex,
  52. selectedWidgetIndex,
  53. widgets,
  54. updateWidget,
  55. showQuerySymbols,
  56. highlightedSampleId,
  57. focusArea,
  58. isMultiChartMode,
  59. metricsSamples,
  60. } = useDDMContext();
  61. const {selection} = usePageFilters();
  62. const router = useRouter();
  63. const organization = useOrganization();
  64. const getChartPalette = useGetCachedChartPalette();
  65. // Make sure all charts are connected to the same group whenever the widgets definition changes
  66. useLayoutEffect(() => {
  67. echarts.connect(DDM_CHART_GROUP);
  68. }, [widgets]);
  69. const handleChange = useCallback(
  70. (index: number, widget: Partial<MetricWidgetQueryParams>) => {
  71. updateWidget(index, widget);
  72. },
  73. [updateWidget]
  74. );
  75. const handleSampleClick = useCallback(
  76. (sample: MetricsSamplesResults<Field>['data'][number]) => {
  77. if (!sample['transaction.id']) {
  78. addErrorMessage(t('No matching transaction found'));
  79. return;
  80. }
  81. router.push(
  82. getMetricsCorrelationSpanUrl(
  83. organization,
  84. sample.project,
  85. sample.id,
  86. sample['transaction.id'],
  87. sample['segment.id']
  88. )
  89. );
  90. },
  91. [router, organization]
  92. );
  93. const firstWidget = widgets[0];
  94. const Wrapper =
  95. !isMultiChartMode || widgets.length === 1
  96. ? StyledSingleWidgetWrapper
  97. : StyledMetricDashboard;
  98. const queriesLookup = useMemo(() => {
  99. const lookup = new Map<string, MetricQueryWidgetParams>();
  100. widgets.forEach(widget => {
  101. if (widget.type === MetricQueryType.QUERY) {
  102. lookup.set(getQuerySymbol(widget.id), widget);
  103. }
  104. });
  105. return lookup;
  106. }, [widgets]);
  107. const getFormulaQueryDependencies = useCallback(
  108. (formula: string): WidgetDependencies => {
  109. let tokens: TokenList = [];
  110. try {
  111. tokens = parseFormula(unescapeMetricsFormula(formula));
  112. } catch {
  113. // We should not end up here, but if we do, we should not crash the UI
  114. return {dependencies: [], isError: true};
  115. }
  116. const dependencies: MetricsQueryApiQueryParams[] = [];
  117. let isError = false;
  118. tokens.forEach(token => {
  119. if (token.type === TokenType.VARIABLE) {
  120. const widget = queriesLookup.get(token.content);
  121. if (widget) {
  122. dependencies.push(widgetToQuery(widget, true));
  123. } else {
  124. isError = true;
  125. }
  126. }
  127. });
  128. return {dependencies, isError};
  129. },
  130. [queriesLookup]
  131. );
  132. const formulaDependencies = useMemo(() => {
  133. return widgets.reduce((acc: Record<number, WidgetDependencies>, widget) => {
  134. if (widget.type === MetricQueryType.FORMULA) {
  135. acc[widget.id] = getFormulaQueryDependencies(widget.formula);
  136. }
  137. return acc;
  138. }, {});
  139. }, [getFormulaQueryDependencies, widgets]);
  140. const filteredWidgets = useMemo(() => {
  141. return widgets.filter(
  142. w =>
  143. w.type !== MetricQueryType.FORMULA || formulaDependencies[w.id]?.isError === false
  144. );
  145. }, [formulaDependencies, widgets]);
  146. return (
  147. <Wrapper>
  148. {isMultiChartMode ? (
  149. filteredWidgets.map((widget, index) =>
  150. widget.isHidden ? null : (
  151. <MultiChartWidgetQueries
  152. formulaDependencies={formulaDependencies}
  153. widget={widget}
  154. key={`${widget.type}_${widget.id}`}
  155. >
  156. {queries => (
  157. <MetricWidget
  158. queryId={widget.id}
  159. index={index}
  160. getChartPalette={getChartPalette}
  161. onSelect={setSelectedWidgetIndex}
  162. displayType={widget.displayType}
  163. focusedSeries={widget.focusedSeries}
  164. tableSort={widget.sort}
  165. queries={queries}
  166. isSelected={selectedWidgetIndex === index}
  167. hasSiblings={widgets.length > 1}
  168. onChange={handleChange}
  169. filters={selection}
  170. focusAreaProps={focusArea}
  171. showQuerySymbols={showQuerySymbols}
  172. onSampleClick={handleSampleClick}
  173. chartHeight={200}
  174. highlightedSampleId={
  175. selectedWidgetIndex === index ? highlightedSampleId : undefined
  176. }
  177. metricsSamples={metricsSamples}
  178. context="ddm"
  179. />
  180. )}
  181. </MultiChartWidgetQueries>
  182. )
  183. )
  184. ) : (
  185. <MetricWidget
  186. index={0}
  187. getChartPalette={getChartPalette}
  188. onSelect={setSelectedWidgetIndex}
  189. displayType={firstWidget.displayType}
  190. focusedSeries={firstWidget.focusedSeries}
  191. tableSort={firstWidget.sort}
  192. queries={filteredWidgets
  193. .filter(w => !(w.type === MetricQueryType.FORMULA && w.isHidden))
  194. .map(w => widgetToQuery(w))}
  195. isSelected
  196. hasSiblings={false}
  197. onChange={handleChange}
  198. filters={selection}
  199. focusAreaProps={focusArea}
  200. showQuerySymbols={false}
  201. onSampleClick={handleSampleClick}
  202. chartHeight={200}
  203. highlightedSampleId={highlightedSampleId}
  204. metricsSamples={metricsSamples}
  205. context="ddm"
  206. />
  207. )}
  208. </Wrapper>
  209. );
  210. }
  211. function MultiChartWidgetQueries({
  212. widget,
  213. formulaDependencies,
  214. children,
  215. }: {
  216. children: (queries: MetricsQueryApiQueryParams[]) => JSX.Element;
  217. formulaDependencies: Record<number, WidgetDependencies>;
  218. widget: MetricWidgetQueryParams;
  219. }) {
  220. const queries = useMemo(() => {
  221. return [
  222. widgetToQuery(widget),
  223. ...(widget.type === MetricQueryType.FORMULA
  224. ? formulaDependencies[widget.id]?.dependencies
  225. : []),
  226. ];
  227. }, [widget, formulaDependencies]);
  228. return children(queries);
  229. }
  230. const StyledMetricDashboard = styled('div')`
  231. display: grid;
  232. grid-template-columns: repeat(3, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  233. gap: ${space(2)};
  234. @media (max-width: ${props => props.theme.breakpoints.xxlarge}) {
  235. grid-template-columns: repeat(2, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  236. }
  237. @media (max-width: ${props => props.theme.breakpoints.xlarge}) {
  238. grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  239. }
  240. grid-auto-rows: auto;
  241. `;
  242. const StyledSingleWidgetWrapper = styled('div')`
  243. display: grid;
  244. grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  245. `;