widgetCard.tsx 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import {Fragment, useMemo} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {ErrorBoundary} from '@sentry/react';
  5. import type {Location} from 'history';
  6. import ErrorPanel from 'sentry/components/charts/errorPanel';
  7. import {HeaderTitle} from 'sentry/components/charts/styles';
  8. import {EquationFormatter} from 'sentry/components/metrics/equationInput/syntax/formatter';
  9. import TextOverflow from 'sentry/components/textOverflow';
  10. import {IconWarning} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {PageFilters} from 'sentry/types/core';
  14. import type {Organization} from 'sentry/types/organization';
  15. import {getFormattedMQL, unescapeMetricsFormula} from 'sentry/utils/metrics';
  16. import {hasMetricsNewInputs} from 'sentry/utils/metrics/features';
  17. import {formatMRIField, MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
  18. import {MetricExpressionType} from 'sentry/utils/metrics/types';
  19. import {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
  20. import {useVirtualMetricsContext} from 'sentry/utils/metrics/virtualMetricsContext';
  21. import {MetricBigNumberContainer} from 'sentry/views/dashboards/metrics/bigNumber';
  22. import {MetricChartContainer} from 'sentry/views/dashboards/metrics/chart';
  23. import {MetricTableContainer} from 'sentry/views/dashboards/metrics/table';
  24. import type {DashboardMetricsExpression} from 'sentry/views/dashboards/metrics/types';
  25. import {
  26. expressionsToApiQueries,
  27. formatAlias,
  28. getMetricExpressions,
  29. isMetricsEquation,
  30. toMetricDisplayType,
  31. } from 'sentry/views/dashboards/metrics/utils';
  32. import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types';
  33. import {DisplayType} from 'sentry/views/dashboards/types';
  34. import {WidgetCardPanel, WidgetTitleRow} from 'sentry/views/dashboards/widgetCard';
  35. import {DashboardsMEPContext} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
  36. import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
  37. import WidgetCardContextMenu from 'sentry/views/dashboards/widgetCard/widgetCardContextMenu';
  38. import {useMetricsIntervalOptions} from 'sentry/views/metrics/utils/useMetricsIntervalParam';
  39. type Props = {
  40. isEditingDashboard: boolean;
  41. location: Location;
  42. organization: Organization;
  43. router: InjectedRouter;
  44. selection: PageFilters;
  45. widget: Widget;
  46. dashboardFilters?: DashboardFilters;
  47. index?: string;
  48. isMobile?: boolean;
  49. onDelete?: () => void;
  50. onDuplicate?: () => void;
  51. onEdit?: (index: string) => void;
  52. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  53. showContextMenu?: boolean;
  54. };
  55. const EMPTY_FN = () => {};
  56. export function getWidgetTitle(expressions: DashboardMetricsExpression[]) {
  57. const filteredExpressions = expressions.filter(query => !query.isQueryOnly);
  58. if (filteredExpressions.length === 1) {
  59. const firstQuery = filteredExpressions[0];
  60. if (isMetricsEquation(firstQuery)) {
  61. return (
  62. <Fragment>
  63. <EquationFormatter equation={unescapeMetricsFormula(firstQuery.formula)} />
  64. </Fragment>
  65. );
  66. }
  67. return formatAlias(firstQuery.alias) ?? getFormattedMQL(firstQuery);
  68. }
  69. return filteredExpressions
  70. .map(q =>
  71. isMetricsEquation(q)
  72. ? formatAlias(q.alias) ?? unescapeMetricsFormula(q.formula)
  73. : formatAlias(q.alias) ?? formatMRIField(MRIToField(q.mri, q.aggregation))
  74. )
  75. .join(', ');
  76. }
  77. export function MetricWidgetCard({
  78. organization,
  79. selection,
  80. widget,
  81. isEditingDashboard,
  82. onDelete,
  83. onDuplicate,
  84. location,
  85. router,
  86. dashboardFilters,
  87. renderErrorMessage,
  88. showContextMenu = true,
  89. }: Props) {
  90. const metricsNewInputs = hasMetricsNewInputs(organization);
  91. const {getVirtualMRIQuery, isLoading: isLoadingVirtualMetrics} =
  92. useVirtualMetricsContext();
  93. const metricExpressions = getMetricExpressions(
  94. widget,
  95. dashboardFilters,
  96. getVirtualMRIQuery
  97. );
  98. const hasSetMetric = useMemo(
  99. () =>
  100. metricExpressions.some(
  101. expression =>
  102. expression.type === MetricExpressionType.QUERY &&
  103. parseMRI(expression.mri)!.type === 's'
  104. ),
  105. [metricExpressions]
  106. );
  107. const widgetMQL = useMemo(
  108. () => (isLoadingVirtualMetrics ? '' : getWidgetTitle(metricExpressions)),
  109. [isLoadingVirtualMetrics, metricExpressions]
  110. );
  111. const metricQueries = useMemo(() => {
  112. const formattedAliasQueries = expressionsToApiQueries(
  113. metricExpressions,
  114. metricsNewInputs
  115. ).map(query => {
  116. if (query.alias) {
  117. return {...query, alias: formatAlias(query.alias)};
  118. }
  119. return query;
  120. });
  121. return [...formattedAliasQueries];
  122. }, [metricExpressions, metricsNewInputs]);
  123. const {interval: validatedInterval} = useMetricsIntervalOptions({
  124. // TODO: Figure out why this can be undefined
  125. interval: widget.interval ?? '',
  126. hasSetMetric,
  127. datetime: selection.datetime,
  128. onIntervalChange: EMPTY_FN,
  129. });
  130. const {
  131. data: timeseriesData,
  132. isPending,
  133. isError,
  134. error,
  135. } = useMetricsQuery(metricQueries, selection, {
  136. interval: validatedInterval,
  137. });
  138. const vizualizationComponent = useMemo(() => {
  139. if (widget.displayType === DisplayType.TABLE) {
  140. return (
  141. <MetricTableContainer
  142. metricQueries={metricQueries}
  143. timeseriesData={timeseriesData}
  144. isLoading={isPending}
  145. />
  146. );
  147. }
  148. if (widget.displayType === DisplayType.BIG_NUMBER) {
  149. return (
  150. <MetricBigNumberContainer timeseriesData={timeseriesData} isLoading={isPending} />
  151. );
  152. }
  153. return (
  154. <MetricChartContainer
  155. timeseriesData={timeseriesData}
  156. isLoading={isPending}
  157. metricQueries={metricQueries}
  158. displayType={toMetricDisplayType(widget.displayType)}
  159. chartHeight={!showContextMenu ? 200 : undefined}
  160. showLegend
  161. />
  162. );
  163. }, [widget.displayType, metricQueries, timeseriesData, isPending, showContextMenu]);
  164. return (
  165. <DashboardsMEPContext.Provider
  166. value={{
  167. isMetricsData: undefined,
  168. setIsMetricsData: () => {},
  169. }}
  170. >
  171. <WidgetCardPanel isDragging={false}>
  172. <WidgetHeaderWrapper>
  173. <WidgetHeaderDescription>
  174. <WidgetTitleRow>
  175. <WidgetTitle>
  176. <TextOverflow>{widget.title || widgetMQL}</TextOverflow>
  177. </WidgetTitle>
  178. </WidgetTitleRow>
  179. </WidgetHeaderDescription>
  180. <ContextMenuWrapper>
  181. {showContextMenu && !isEditingDashboard && (
  182. <WidgetCardContextMenu
  183. organization={organization}
  184. widget={widget}
  185. selection={selection}
  186. showContextMenu
  187. isPreview={false}
  188. widgetLimitReached={false}
  189. onEdit={() => {
  190. router.push({
  191. pathname: `${location.pathname}${
  192. location.pathname.endsWith('/') ? '' : '/'
  193. }widget/${widget.id}/`,
  194. query: location.query,
  195. });
  196. }}
  197. router={router}
  198. location={location}
  199. onDelete={onDelete}
  200. onDuplicate={onDuplicate}
  201. />
  202. )}
  203. </ContextMenuWrapper>
  204. </WidgetHeaderWrapper>
  205. <ErrorBoundary>
  206. <WidgetCardBody
  207. isError={isError}
  208. timeseriesData={timeseriesData}
  209. renderErrorMessage={renderErrorMessage}
  210. error={error}
  211. >
  212. {vizualizationComponent}
  213. </WidgetCardBody>
  214. </ErrorBoundary>
  215. {isEditingDashboard && <Toolbar onDelete={onDelete} onDuplicate={onDuplicate} />}
  216. </WidgetCardPanel>
  217. </DashboardsMEPContext.Provider>
  218. );
  219. }
  220. function WidgetCardBody({children, isError, timeseriesData, renderErrorMessage, error}) {
  221. if (isError && !timeseriesData) {
  222. const errorMessage =
  223. error?.responseJSON?.detail?.toString() || t('Error while fetching metrics data');
  224. return (
  225. <ErrorWrapper>
  226. {renderErrorMessage?.(errorMessage)}
  227. <ErrorPanel>
  228. <IconWarning color="gray500" size="lg" />
  229. </ErrorPanel>
  230. </ErrorWrapper>
  231. );
  232. }
  233. return children;
  234. }
  235. const WidgetHeaderWrapper = styled('div')`
  236. min-height: 36px;
  237. width: 100%;
  238. display: flex;
  239. align-items: flex-start;
  240. justify-content: space-between;
  241. `;
  242. const ContextMenuWrapper = styled('div')`
  243. padding: ${space(2)} ${space(1)} 0 ${space(3)};
  244. `;
  245. const WidgetHeaderDescription = styled('div')`
  246. ${p => p.theme.overflowEllipsis};
  247. overflow-y: visible;
  248. `;
  249. const WidgetTitle = styled(HeaderTitle)`
  250. padding-left: ${space(3)};
  251. padding-top: ${space(2)};
  252. padding-right: ${space(1)};
  253. ${p => p.theme.overflowEllipsis};
  254. font-weight: ${p => p.theme.fontWeightNormal};
  255. `;
  256. const ErrorWrapper = styled('div')`
  257. padding-top: ${space(1)};
  258. `;