index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
  2. import type {InjectedRouter} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import type {Location} from 'history';
  5. import ErrorPanel from 'sentry/components/charts/errorPanel';
  6. import {HeaderTitle} from 'sentry/components/charts/styles';
  7. import TransitionChart from 'sentry/components/charts/transitionChart';
  8. import EmptyMessage from 'sentry/components/emptyMessage';
  9. import TextOverflow from 'sentry/components/textOverflow';
  10. import {IconSearch, IconWarning} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {MRI, Organization, PageFilters} from 'sentry/types';
  14. import type {ReactEchartsRef} from 'sentry/types/echarts';
  15. import {stringifyMetricWidget} from 'sentry/utils/metrics';
  16. import {
  17. MetricDisplayType,
  18. type MetricWidgetQueryParams,
  19. } from 'sentry/utils/metrics/types';
  20. import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  21. import {WidgetCardPanel, WidgetTitleRow} from 'sentry/views/dashboards/widgetCard';
  22. import type {AugmentedEChartDataZoomHandler} from 'sentry/views/dashboards/widgetCard/chart';
  23. import {DashboardsMEPContext} from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
  24. import {InlineEditor} from 'sentry/views/dashboards/widgetCard/metricWidgetCard/inlineEditor';
  25. import {Toolbar} from 'sentry/views/dashboards/widgetCard/toolbar';
  26. import WidgetCardContextMenu from 'sentry/views/dashboards/widgetCard/widgetCardContextMenu';
  27. import {MetricChart} from 'sentry/views/ddm/chart';
  28. import {createChartPalette} from 'sentry/views/ddm/metricsChartPalette';
  29. import {getChartTimeseries} from 'sentry/views/ddm/widget';
  30. import {LoadingScreen} from 'sentry/views/starfish/components/chart';
  31. import {
  32. convertToDashboardWidget,
  33. toMetricDisplayType,
  34. } from '../../../../utils/metrics/dashboard';
  35. import {parseField} from '../../../../utils/metrics/mri';
  36. import {DASHBOARD_CHART_GROUP} from '../../dashboard';
  37. import type {DashboardFilters, Widget} from '../../types';
  38. import {useMetricsDashboardContext} from '../metricsContext';
  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. isEditingWidget?: boolean;
  49. isMobile?: boolean;
  50. onDelete?: () => void;
  51. onDuplicate?: () => void;
  52. onEdit?: (index: string) => void;
  53. onUpdate?: (widget: Widget | null) => void;
  54. onZoom?: AugmentedEChartDataZoomHandler;
  55. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  56. showSlider?: boolean;
  57. tableItemLimit?: number;
  58. windowWidth?: number;
  59. };
  60. export function MetricWidgetCard({
  61. organization,
  62. selection,
  63. widget,
  64. isEditingWidget,
  65. isEditingDashboard,
  66. onEdit,
  67. onUpdate,
  68. onDelete,
  69. onDuplicate,
  70. location,
  71. router,
  72. index,
  73. dashboardFilters,
  74. renderErrorMessage,
  75. }: Props) {
  76. useMetricsDashboardContext();
  77. const [metricWidgetQueryParams, setMetricWidgetQueryParams] =
  78. useState<MetricWidgetQueryParams>(convertFromWidget(widget));
  79. const defaultTitle = useMemo(
  80. () => stringifyMetricWidget(metricWidgetQueryParams),
  81. [metricWidgetQueryParams]
  82. );
  83. const [title, setTitle] = useState<string>(widget.title ?? defaultTitle);
  84. const handleChange = useCallback(
  85. (data: Partial<MetricWidgetQueryParams>) => {
  86. setMetricWidgetQueryParams(curr => ({
  87. ...curr,
  88. ...data,
  89. }));
  90. },
  91. [setMetricWidgetQueryParams]
  92. );
  93. const handleSubmit = useCallback(() => {
  94. const convertedWidget = convertToDashboardWidget(
  95. {...selection, ...metricWidgetQueryParams},
  96. toMetricDisplayType(metricWidgetQueryParams.displayType)
  97. );
  98. const isCustomTitle = title !== '' && title !== defaultTitle;
  99. const updatedWidget = {
  100. ...widget,
  101. // If user renamed the widget, preserve that title, otherwise stringify the widget query params
  102. title: isCustomTitle ? title : defaultTitle,
  103. queries: convertedWidget.queries,
  104. displayType: convertedWidget.displayType,
  105. };
  106. onUpdate?.(updatedWidget);
  107. }, [title, defaultTitle, metricWidgetQueryParams, onUpdate, widget, selection]);
  108. const handleCancel = useCallback(() => {
  109. onUpdate?.(null);
  110. }, [onUpdate]);
  111. if (!metricWidgetQueryParams.mri) {
  112. return (
  113. <ErrorPanel height="200px">
  114. <IconWarning color="gray300" size="lg" />
  115. </ErrorPanel>
  116. );
  117. }
  118. return (
  119. <DashboardsMEPContext.Provider
  120. value={{
  121. isMetricsData: undefined,
  122. setIsMetricsData: () => {},
  123. }}
  124. >
  125. <WidgetCardPanel isDragging={false}>
  126. <WidgetHeaderWrapper>
  127. {isEditingWidget ? (
  128. <InlineEditor
  129. isEdit={!!isEditingWidget}
  130. displayType={metricWidgetQueryParams.displayType}
  131. metricsQuery={metricWidgetQueryParams}
  132. projects={selection.projects}
  133. powerUserMode={false}
  134. onChange={handleChange}
  135. onSubmit={handleSubmit}
  136. onCancel={handleCancel}
  137. onTitleChange={setTitle}
  138. title={title}
  139. />
  140. ) : (
  141. <WidgetHeaderDescription>
  142. <WidgetTitleRow>
  143. <WidgetTitle>
  144. <TextOverflow>{title}</TextOverflow>
  145. </WidgetTitle>
  146. </WidgetTitleRow>
  147. </WidgetHeaderDescription>
  148. )}
  149. <ContextMenuWrapper>
  150. {!isEditingDashboard && (
  151. <WidgetCardContextMenu
  152. organization={organization}
  153. widget={widget}
  154. selection={selection}
  155. showContextMenu
  156. isPreview={false}
  157. widgetLimitReached={false}
  158. onEdit={() => index && onEdit?.(index)}
  159. router={router}
  160. location={location}
  161. onDelete={onDelete}
  162. onDuplicate={onDuplicate}
  163. />
  164. )}
  165. </ContextMenuWrapper>
  166. </WidgetHeaderWrapper>
  167. <MetricWidgetChartContainer
  168. selection={selection}
  169. widget={widget}
  170. editorParams={metricWidgetQueryParams}
  171. dashboardFilters={dashboardFilters}
  172. renderErrorMessage={renderErrorMessage}
  173. />
  174. {isEditingDashboard && <Toolbar onDelete={onDelete} onDuplicate={onDuplicate} />}
  175. </WidgetCardPanel>
  176. </DashboardsMEPContext.Provider>
  177. );
  178. }
  179. type MetricWidgetChartContainerProps = {
  180. selection: PageFilters;
  181. widget: Widget;
  182. dashboardFilters?: DashboardFilters;
  183. editorParams?: Partial<MetricWidgetQueryParams>;
  184. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  185. };
  186. export function MetricWidgetChartContainer({
  187. selection,
  188. widget,
  189. editorParams = {},
  190. dashboardFilters,
  191. renderErrorMessage,
  192. }: MetricWidgetChartContainerProps) {
  193. const metricWidgetQueryParams = {
  194. ...convertFromWidget(widget),
  195. ...editorParams,
  196. };
  197. const {projects, environments, datetime} = selection;
  198. const {mri, op, groupBy, displayType} = metricWidgetQueryParams;
  199. const {
  200. data: timeseriesData,
  201. isLoading,
  202. isError,
  203. error,
  204. } = useMetricsDataZoom(
  205. {
  206. mri,
  207. op,
  208. query: extendQuery(metricWidgetQueryParams.query, dashboardFilters),
  209. groupBy,
  210. projects,
  211. environments,
  212. datetime,
  213. },
  214. {intervalLadder: displayType === MetricDisplayType.BAR ? 'bar' : 'dashboard'}
  215. );
  216. const chartRef = useRef<ReactEchartsRef>(null);
  217. const chartSeries = useMemo(() => {
  218. return timeseriesData
  219. ? getChartTimeseries(timeseriesData, {
  220. getChartPalette: createChartPalette,
  221. mri,
  222. })
  223. : [];
  224. }, [timeseriesData, mri]);
  225. if (isError) {
  226. const errorMessage =
  227. error?.responseJSON?.detail?.toString() || t('Error while fetching metrics data');
  228. return (
  229. <Fragment>
  230. {renderErrorMessage?.(errorMessage)}
  231. <ErrorPanel>
  232. <IconWarning color="gray500" size="lg" />
  233. </ErrorPanel>
  234. </Fragment>
  235. );
  236. }
  237. if (timeseriesData?.groups.length === 0) {
  238. return (
  239. <EmptyMessage
  240. icon={<IconSearch size="xxl" />}
  241. title={t('No results')}
  242. description={t('No results found for the given query')}
  243. />
  244. );
  245. }
  246. return (
  247. <MetricWidgetChartWrapper>
  248. <TransitionChart loading={isLoading} reloading={isLoading}>
  249. <LoadingScreen loading={isLoading} />
  250. <MetricChart
  251. ref={chartRef}
  252. series={chartSeries}
  253. displayType={displayType}
  254. operation={op}
  255. widgetIndex={0}
  256. group={DASHBOARD_CHART_GROUP}
  257. />
  258. </TransitionChart>
  259. </MetricWidgetChartWrapper>
  260. );
  261. }
  262. function extendQuery(query = '', dashboardFilters?: DashboardFilters) {
  263. if (!dashboardFilters?.release?.length) {
  264. return query;
  265. }
  266. const releaseQuery = convertToQuery(dashboardFilters);
  267. return `${query} ${releaseQuery}`;
  268. }
  269. function convertToQuery(dashboardFilters: DashboardFilters) {
  270. const {release} = dashboardFilters;
  271. if (!release?.length) {
  272. return '';
  273. }
  274. if (release.length === 1) {
  275. return `release:${release[0]}`;
  276. }
  277. return `release:[${release.join(',')}]`;
  278. }
  279. function convertFromWidget(widget: Widget): MetricWidgetQueryParams {
  280. const query = widget.queries[0];
  281. const parsed = parseField(query.aggregates[0]) || {mri: '' as MRI, op: ''};
  282. return {
  283. mri: parsed.mri,
  284. op: parsed.op,
  285. query: query.conditions,
  286. groupBy: query.columns,
  287. title: widget.title,
  288. displayType: toMetricDisplayType(widget.displayType),
  289. };
  290. }
  291. const WidgetHeaderWrapper = styled('div')`
  292. min-height: 36px;
  293. width: 100%;
  294. display: flex;
  295. align-items: flex-start;
  296. justify-content: space-between;
  297. `;
  298. const ContextMenuWrapper = styled('div')`
  299. padding: ${space(2)} ${space(1)} 0 ${space(3)};
  300. `;
  301. const WidgetHeaderDescription = styled('div')`
  302. ${p => p.theme.overflowEllipsis};
  303. overflow-y: visible;
  304. `;
  305. const WidgetTitle = styled(HeaderTitle)`
  306. padding-left: ${space(3)};
  307. padding-top: ${space(2)};
  308. padding-right: ${space(1)};
  309. ${p => p.theme.overflowEllipsis};
  310. font-weight: normal;
  311. `;
  312. const MetricWidgetChartWrapper = styled('div')`
  313. height: 100%;
  314. width: 100%;
  315. padding: ${space(3)};
  316. padding-top: ${space(2)};
  317. `;