scratchpad.tsx 8.4 KB

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