index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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 {useMetricsQuery} from 'sentry/utils/metrics/useMetricsQuery';
  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 {MRIToField, 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. onUpdate,
  67. onDelete,
  68. onDuplicate,
  69. location,
  70. router,
  71. dashboardFilters,
  72. renderErrorMessage,
  73. }: Props) {
  74. useMetricsDashboardContext();
  75. const metricWidgetQueryParams = convertFromWidget(widget);
  76. const defaultTitle = useMemo(
  77. () => stringifyMetricWidget(metricWidgetQueryParams),
  78. [metricWidgetQueryParams]
  79. );
  80. const [title, setTitle] = useState<string>(widget.title ?? defaultTitle);
  81. const handleSubmit = useCallback(() => {
  82. const convertedWidget = convertToDashboardWidget(
  83. {...selection, ...metricWidgetQueryParams},
  84. toMetricDisplayType(metricWidgetQueryParams.displayType)
  85. );
  86. const isCustomTitle = title !== '' && title !== defaultTitle;
  87. const updatedWidget = {
  88. ...widget,
  89. // If user renamed the widget, preserve that title, otherwise stringify the widget query params
  90. title: isCustomTitle ? title : defaultTitle,
  91. queries: convertedWidget.queries,
  92. displayType: convertedWidget.displayType,
  93. };
  94. onUpdate?.(updatedWidget);
  95. }, [title, defaultTitle, metricWidgetQueryParams, onUpdate, widget, selection]);
  96. const handleCancel = useCallback(() => {
  97. onUpdate?.(null);
  98. }, [onUpdate]);
  99. if (!metricWidgetQueryParams.mri) {
  100. return (
  101. <ErrorPanel height="200px">
  102. <IconWarning color="gray300" size="lg" />
  103. </ErrorPanel>
  104. );
  105. }
  106. return (
  107. <DashboardsMEPContext.Provider
  108. value={{
  109. isMetricsData: undefined,
  110. setIsMetricsData: () => {},
  111. }}
  112. >
  113. <WidgetCardPanel isDragging={false}>
  114. <WidgetHeaderWrapper>
  115. {isEditingWidget ? (
  116. <InlineEditor
  117. isEdit={!!isEditingWidget}
  118. displayType={metricWidgetQueryParams.displayType}
  119. metricsQuery={metricWidgetQueryParams}
  120. projects={selection.projects}
  121. powerUserMode={false}
  122. // TODO: remove in a followup
  123. onChange={() => {}}
  124. onSubmit={handleSubmit}
  125. onCancel={handleCancel}
  126. onTitleChange={setTitle}
  127. title={title}
  128. />
  129. ) : (
  130. <WidgetHeaderDescription>
  131. <WidgetTitleRow>
  132. <WidgetTitle>
  133. <TextOverflow>{title}</TextOverflow>
  134. </WidgetTitle>
  135. </WidgetTitleRow>
  136. </WidgetHeaderDescription>
  137. )}
  138. <ContextMenuWrapper>
  139. {!isEditingDashboard && (
  140. <WidgetCardContextMenu
  141. organization={organization}
  142. widget={widget}
  143. selection={selection}
  144. showContextMenu
  145. isPreview={false}
  146. widgetLimitReached={false}
  147. onEdit={() => {
  148. router.push({
  149. pathname: `${location.pathname}${
  150. location.pathname.endsWith('/') ? '' : '/'
  151. }widget/${widget.id}/`,
  152. query: location.query,
  153. });
  154. }}
  155. router={router}
  156. location={location}
  157. onDelete={onDelete}
  158. onDuplicate={onDuplicate}
  159. />
  160. )}
  161. </ContextMenuWrapper>
  162. </WidgetHeaderWrapper>
  163. <MetricWidgetChartContainer
  164. selection={selection}
  165. widget={widget}
  166. editorParams={metricWidgetQueryParams}
  167. dashboardFilters={dashboardFilters}
  168. renderErrorMessage={renderErrorMessage}
  169. />
  170. {isEditingDashboard && <Toolbar onDelete={onDelete} onDuplicate={onDuplicate} />}
  171. </WidgetCardPanel>
  172. </DashboardsMEPContext.Provider>
  173. );
  174. }
  175. type MetricWidgetChartContainerProps = {
  176. selection: PageFilters;
  177. widget: Widget;
  178. dashboardFilters?: DashboardFilters;
  179. editorParams?: Partial<MetricWidgetQueryParams>;
  180. renderErrorMessage?: (errorMessage?: string) => React.ReactNode;
  181. };
  182. export function MetricWidgetChartContainer({
  183. selection,
  184. widget,
  185. editorParams = {},
  186. dashboardFilters,
  187. renderErrorMessage,
  188. }: MetricWidgetChartContainerProps) {
  189. const metricWidgetQueryParams = {
  190. ...convertFromWidget(widget),
  191. ...editorParams,
  192. };
  193. const {projects, environments, datetime} = selection;
  194. const {mri, op, groupBy, displayType} = metricWidgetQueryParams;
  195. const {
  196. data: timeseriesData,
  197. isLoading,
  198. isError,
  199. error,
  200. } = useMetricsQuery(
  201. [
  202. {
  203. mri,
  204. op,
  205. query: extendQuery(metricWidgetQueryParams.query, dashboardFilters),
  206. groupBy,
  207. },
  208. ],
  209. {
  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. field: MRIToField(mri, op || ''),
  223. })
  224. : [];
  225. }, [timeseriesData, mri, op]);
  226. if (isError) {
  227. const errorMessage =
  228. error?.responseJSON?.detail?.toString() || t('Error while fetching metrics data');
  229. return (
  230. <Fragment>
  231. {renderErrorMessage?.(errorMessage)}
  232. <ErrorPanel>
  233. <IconWarning color="gray500" size="lg" />
  234. </ErrorPanel>
  235. </Fragment>
  236. );
  237. }
  238. if (timeseriesData?.data.length === 0) {
  239. return (
  240. <EmptyMessage
  241. icon={<IconSearch size="xxl" />}
  242. title={t('No results')}
  243. description={t('No results found for the given query')}
  244. />
  245. );
  246. }
  247. return (
  248. <MetricWidgetChartWrapper>
  249. <TransitionChart loading={isLoading} reloading={isLoading}>
  250. <LoadingScreen loading={isLoading} />
  251. <MetricChart
  252. ref={chartRef}
  253. series={chartSeries}
  254. displayType={displayType}
  255. operation={op}
  256. widgetIndex={0}
  257. group={DASHBOARD_CHART_GROUP}
  258. />
  259. </TransitionChart>
  260. </MetricWidgetChartWrapper>
  261. );
  262. }
  263. function convertFromWidget(widget: Widget): MetricWidgetQueryParams {
  264. const query = widget.queries[0];
  265. const parsed = parseField(query.aggregates[0]) || {mri: '' as MRI, op: ''};
  266. return {
  267. mri: parsed.mri,
  268. op: parsed.op,
  269. query: query.conditions,
  270. groupBy: query.columns,
  271. displayType: toMetricDisplayType(widget.displayType),
  272. };
  273. }
  274. function extendQuery(query = '', dashboardFilters?: DashboardFilters) {
  275. if (!dashboardFilters?.release?.length) {
  276. return query;
  277. }
  278. const releaseQuery = convertToQuery(dashboardFilters);
  279. return `${query} ${releaseQuery}`;
  280. }
  281. function convertToQuery(dashboardFilters: DashboardFilters) {
  282. const {release} = dashboardFilters;
  283. if (!release?.length) {
  284. return '';
  285. }
  286. if (release.length === 1) {
  287. return `release:${release[0]}`;
  288. }
  289. return `release:[${release.join(',')}]`;
  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. `;