scratchpad.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 || widget.isHidden,
  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. widget.isHidden ? null : (
  156. <MultiChartWidgetQueries
  157. formulaDependencies={formulaDependencies}
  158. widget={widget}
  159. key={widget.id}
  160. >
  161. {queries => (
  162. <MetricWidget
  163. queryId={widget.id}
  164. index={index}
  165. getChartPalette={getChartPalette}
  166. onSelect={setSelectedWidgetIndex}
  167. displayType={widget.displayType}
  168. focusedSeries={widget.focusedSeries}
  169. tableSort={widget.sort}
  170. queries={queries}
  171. isSelected={selectedWidgetIndex === index}
  172. hasSiblings={widgets.length > 1}
  173. onChange={handleChange}
  174. filters={selection}
  175. focusAreaProps={focusArea}
  176. showQuerySymbols={showQuerySymbols}
  177. onSampleClick={handleSampleClick}
  178. onSampleClickV2={handleSampleClickV2}
  179. chartHeight={200}
  180. highlightedSampleId={
  181. selectedWidgetIndex === index ? highlightedSampleId : undefined
  182. }
  183. metricsSamples={metricsSamples}
  184. context="ddm"
  185. />
  186. )}
  187. </MultiChartWidgetQueries>
  188. )
  189. )
  190. ) : (
  191. <MetricWidget
  192. index={0}
  193. getChartPalette={getChartPalette}
  194. onSelect={setSelectedWidgetIndex}
  195. displayType={firstWidget.displayType}
  196. focusedSeries={firstWidget.focusedSeries}
  197. tableSort={firstWidget.sort}
  198. queries={filteredWidgets
  199. .filter(w => !(w.type === MetricQueryType.FORMULA && w.isHidden))
  200. .map(w => widgetToQuery(w))}
  201. isSelected
  202. hasSiblings={false}
  203. onChange={handleChange}
  204. filters={selection}
  205. focusAreaProps={focusArea}
  206. showQuerySymbols={false}
  207. onSampleClick={handleSampleClick}
  208. onSampleClickV2={handleSampleClickV2}
  209. chartHeight={200}
  210. highlightedSampleId={highlightedSampleId}
  211. metricsSamples={metricsSamples}
  212. context="ddm"
  213. />
  214. )}
  215. </Wrapper>
  216. );
  217. }
  218. function MultiChartWidgetQueries({
  219. widget,
  220. formulaDependencies,
  221. children,
  222. }: {
  223. children: (queries: MetricsQueryApiQueryParams[]) => JSX.Element;
  224. formulaDependencies: Record<number, WidgetDependencies>;
  225. widget: MetricWidgetQueryParams;
  226. }) {
  227. const queries = useMemo(() => {
  228. return [
  229. widgetToQuery(widget),
  230. ...(widget.type === MetricQueryType.FORMULA
  231. ? formulaDependencies[widget.id]?.dependencies
  232. : []),
  233. ];
  234. }, [widget, formulaDependencies]);
  235. return children(queries);
  236. }
  237. const StyledMetricDashboard = styled('div')`
  238. display: grid;
  239. grid-template-columns: repeat(3, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  240. gap: ${space(2)};
  241. @media (max-width: ${props => props.theme.breakpoints.xxlarge}) {
  242. grid-template-columns: repeat(2, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  243. }
  244. @media (max-width: ${props => props.theme.breakpoints.xlarge}) {
  245. grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  246. }
  247. grid-auto-rows: auto;
  248. `;
  249. const StyledSingleWidgetWrapper = styled('div')`
  250. display: grid;
  251. grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
  252. `;