metricWidget.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import {Fragment, useEffect, useRef, useState} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import colorFn from 'color';
  5. import type {LineSeriesOption} from 'echarts';
  6. import * as echarts from 'echarts/core';
  7. import moment from 'moment';
  8. import Alert from 'sentry/components/alert';
  9. import {Button} from 'sentry/components/button';
  10. import {AreaChart} from 'sentry/components/charts/areaChart';
  11. import {BarChart} from 'sentry/components/charts/barChart';
  12. import ChartZoom from 'sentry/components/charts/chartZoom';
  13. import Legend from 'sentry/components/charts/components/legend';
  14. import {LineChart} from 'sentry/components/charts/lineChart';
  15. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  16. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  17. import {RELEASE_LINES_THRESHOLD} from 'sentry/components/charts/utils';
  18. import EmptyMessage from 'sentry/components/emptyMessage';
  19. import LoadingIndicator from 'sentry/components/loadingIndicator';
  20. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  21. import Panel from 'sentry/components/panels/panel';
  22. import PanelBody from 'sentry/components/panels/panelBody';
  23. import {IconAdd, IconSearch} from 'sentry/icons';
  24. import {t} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import {PageFilters} from 'sentry/types';
  27. import {ReactEchartsRef} from 'sentry/types/echarts';
  28. import {
  29. defaultMetricDisplayType,
  30. formatMetricsUsingUnitAndOp,
  31. getNameFromMRI,
  32. getUnitFromMRI,
  33. MetricDisplayType,
  34. MetricsData,
  35. MetricsDataProps,
  36. MetricsQuery,
  37. updateQuery,
  38. useMetricsData,
  39. } from 'sentry/utils/metrics';
  40. import {decodeList} from 'sentry/utils/queryString';
  41. import theme from 'sentry/utils/theme';
  42. import usePageFilters from 'sentry/utils/usePageFilters';
  43. import useRouter from 'sentry/utils/useRouter';
  44. import {QueryBuilder} from 'sentry/views/ddm/metricQueryBuilder';
  45. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  46. import {getFormatter} from '../../components/charts/components/tooltip';
  47. const DDM_CHART_GROUP = 'ddm_chart_group';
  48. const emptyWidget = {
  49. mri: '',
  50. op: undefined,
  51. query: '',
  52. groupBy: [],
  53. displayType: defaultMetricDisplayType,
  54. };
  55. export type MetricWidgetDisplayConfig = {
  56. displayType: MetricDisplayType;
  57. onChange: (data: Partial<MetricWidgetProps>) => void;
  58. position: number;
  59. focusedSeries?: string;
  60. powerUserMode?: boolean;
  61. showSummaryTable?: boolean;
  62. };
  63. export type MetricWidgetProps = MetricsQuery & MetricWidgetDisplayConfig;
  64. function useMetricWidgets() {
  65. const router = useRouter();
  66. const currentWidgets = JSON.parse(
  67. router.location.query.widgets ?? JSON.stringify([emptyWidget])
  68. );
  69. const widgets: MetricWidgetProps[] = currentWidgets.map(
  70. (widget: MetricWidgetProps, i) => {
  71. return {
  72. mri: widget.mri,
  73. op: widget.op,
  74. query: widget.query,
  75. groupBy: decodeList(widget.groupBy),
  76. displayType: widget.displayType ?? defaultMetricDisplayType,
  77. focusedSeries: widget.focusedSeries,
  78. showSummaryTable: widget.showSummaryTable ?? true, // temporary default
  79. position: widget.position ?? i,
  80. powerUserMode: widget.powerUserMode,
  81. };
  82. }
  83. );
  84. const onChange = (position: number, data: Partial<MetricWidgetProps>) => {
  85. currentWidgets[position] = {...currentWidgets[position], ...data};
  86. updateQuery(router, {
  87. widgets: JSON.stringify(currentWidgets),
  88. });
  89. };
  90. const addWidget = () => {
  91. currentWidgets.push({...emptyWidget, position: currentWidgets.length});
  92. updateQuery(router, {
  93. widgets: JSON.stringify(currentWidgets),
  94. });
  95. };
  96. return {
  97. widgets,
  98. onChange,
  99. addWidget,
  100. };
  101. }
  102. function MetricDashboard() {
  103. const {widgets, onChange, addWidget} = useMetricWidgets();
  104. const {selection} = usePageFilters();
  105. const Wrapper =
  106. widgets.length === 1 ? StyledSingleWidgetWrapper : StyledMetricDashboard;
  107. echarts.connect(DDM_CHART_GROUP);
  108. return (
  109. <Wrapper>
  110. {widgets.map(widget => (
  111. <MetricWidget
  112. key={widget.position}
  113. widget={{
  114. ...widget,
  115. onChange: data => {
  116. onChange(widget.position, data);
  117. },
  118. }}
  119. datetime={selection.datetime}
  120. projects={selection.projects}
  121. environments={selection.environments}
  122. />
  123. ))}
  124. <AddWidgetPanel onClick={addWidget}>
  125. <Button priority="primary" icon={<IconAdd isCircled />}>
  126. Add widget
  127. </Button>
  128. </AddWidgetPanel>
  129. </Wrapper>
  130. );
  131. }
  132. // TODO(ddm): reuse from types/metrics.tsx
  133. type Group = {
  134. by: Record<string, unknown>;
  135. series: Record<string, number[]>;
  136. totals: Record<string, number>;
  137. };
  138. type DisplayProps = MetricWidgetProps & MetricsDataProps;
  139. export function MetricWidget({
  140. widget,
  141. datetime,
  142. projects,
  143. environments,
  144. }: {
  145. datetime: PageFilters['datetime'];
  146. environments: PageFilters['environments'];
  147. projects: PageFilters['projects'];
  148. widget: MetricWidgetProps;
  149. }) {
  150. return (
  151. <MetricWidgetPanel key={widget.position}>
  152. <PanelBody>
  153. <QueryBuilder
  154. metricsQuery={{
  155. mri: widget.mri,
  156. query: widget.query,
  157. op: widget.op,
  158. groupBy: widget.groupBy,
  159. }}
  160. projects={projects}
  161. displayType={widget.displayType}
  162. onChange={widget.onChange}
  163. powerUserMode={widget.powerUserMode}
  164. />
  165. <MetricWidgetBody
  166. datetime={datetime}
  167. projects={projects}
  168. environments={environments}
  169. {...widget}
  170. />
  171. </PanelBody>
  172. </MetricWidgetPanel>
  173. );
  174. }
  175. function MetricWidgetBody(props?: DisplayProps) {
  176. if (!props?.mri) {
  177. return (
  178. <StyledMetricWidgetBody>
  179. <EmptyMessage
  180. icon={<IconSearch size="xxl" />}
  181. title={t('Nothing to show!')}
  182. description={t('Choose a metric to display data.')}
  183. />
  184. </StyledMetricWidgetBody>
  185. );
  186. }
  187. return <MetricWidgetBodyInner {...props} />;
  188. }
  189. function MetricWidgetBodyInner({
  190. onChange,
  191. displayType,
  192. focusedSeries,
  193. ...metricsDataProps
  194. }: DisplayProps) {
  195. const {data, isLoading, isError, error} = useMetricsData(metricsDataProps);
  196. const [dataToBeRendered, setDataToBeRendered] = useState<MetricsData | undefined>(
  197. undefined
  198. );
  199. const [hoveredLegend, setHoveredLegend] = useState('');
  200. useEffect(() => {
  201. if (data) {
  202. setDataToBeRendered(data);
  203. }
  204. }, [data]);
  205. const toggleSeriesVisibility = (seriesName: string) => {
  206. setHoveredLegend('');
  207. onChange({
  208. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  209. });
  210. };
  211. if (!dataToBeRendered || isError) {
  212. return (
  213. <StyledMetricWidgetBody>
  214. {isLoading && <LoadingIndicator />}
  215. {isError && (
  216. <Alert type="error">
  217. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  218. </Alert>
  219. )}
  220. </StyledMetricWidgetBody>
  221. );
  222. }
  223. // TODO(ddm): we should move this into the useMetricsData hook
  224. const sorted = sortData(dataToBeRendered);
  225. const unit = getUnitFromMRI(Object.keys(dataToBeRendered.groups[0]?.series ?? {})[0]); // this assumes that all series have the same unit
  226. const series = sorted.groups.map(g => {
  227. return {
  228. values: Object.values(g.series)[0],
  229. name: getSeriesName(
  230. g,
  231. dataToBeRendered.groups.length === 1,
  232. metricsDataProps.groupBy
  233. ),
  234. transaction: g.by.transaction,
  235. release: g.by.release,
  236. };
  237. });
  238. const colors = theme.charts.getColorPalette(series.length);
  239. const chartSeries = series.map((item, i) => ({
  240. seriesName: item.name,
  241. unit,
  242. color: colorFn(colors[i])
  243. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  244. .string(),
  245. hidden: focusedSeries && focusedSeries !== item.name,
  246. data: item.values.map((value, index) => ({
  247. name: sorted.intervals[index],
  248. value,
  249. })),
  250. transaction: item.transaction as string | undefined,
  251. release: item.release as string | undefined,
  252. emphasis: {
  253. focus: 'series',
  254. } as LineSeriesOption['emphasis'],
  255. })) as Series[];
  256. return (
  257. <StyledMetricWidgetBody>
  258. <TransparentLoadingMask visible={isLoading} />
  259. <MetricChart
  260. series={chartSeries}
  261. displayType={displayType}
  262. operation={metricsDataProps.op}
  263. projects={metricsDataProps.projects}
  264. environments={metricsDataProps.environments}
  265. {...normalizeChartTimeParams(sorted)}
  266. />
  267. {metricsDataProps.showSummaryTable && (
  268. <SummaryTable
  269. series={chartSeries}
  270. operation={metricsDataProps.op}
  271. onClick={toggleSeriesVisibility}
  272. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  273. />
  274. )}
  275. </StyledMetricWidgetBody>
  276. );
  277. }
  278. function getSeriesName(
  279. group: Group,
  280. isOnlyGroup = false,
  281. groupBy: MetricsDataProps['groupBy']
  282. ) {
  283. if (isOnlyGroup && !groupBy?.length) {
  284. return Object.keys(group.series)?.[0] ?? '(none)';
  285. }
  286. return Object.entries(group.by)
  287. .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
  288. .join(', ');
  289. }
  290. function sortData(data: MetricsData): MetricsData {
  291. if (!data.groups.length) {
  292. return data;
  293. }
  294. const key = Object.keys(data.groups[0].totals)[0];
  295. const sortedGroups = data.groups.sort((a, b) =>
  296. a.totals[key] < b.totals[key] ? 1 : -1
  297. );
  298. return {
  299. ...data,
  300. groups: sortedGroups,
  301. };
  302. }
  303. function normalizeChartTimeParams(data: MetricsData) {
  304. const {
  305. start,
  306. end,
  307. utc: utcString,
  308. statsPeriod,
  309. } = normalizeDateTimeParams(data, {
  310. allowEmptyPeriod: true,
  311. allowAbsoluteDatetime: true,
  312. allowAbsolutePageDatetime: true,
  313. });
  314. const utc = utcString === 'true';
  315. if (start && end) {
  316. return utc
  317. ? {
  318. start: moment.utc(start).format(),
  319. end: moment.utc(end).format(),
  320. utc,
  321. }
  322. : {
  323. start: moment(start).utc().format(),
  324. end: moment(end).utc().format(),
  325. utc,
  326. };
  327. }
  328. return {
  329. period: statsPeriod ?? '90d',
  330. };
  331. }
  332. export type Series = {
  333. color: string;
  334. data: {name: string; value: number}[];
  335. seriesName: string;
  336. unit: string;
  337. hidden?: boolean;
  338. release?: string;
  339. transaction?: string;
  340. };
  341. type ChartProps = {
  342. displayType: MetricDisplayType;
  343. environments: PageFilters['environments'];
  344. projects: PageFilters['projects'];
  345. series: Series[];
  346. end?: string;
  347. operation?: string;
  348. period?: string;
  349. start?: string;
  350. utc?: boolean;
  351. };
  352. function MetricChart({
  353. series,
  354. displayType,
  355. start,
  356. end,
  357. period,
  358. utc,
  359. operation,
  360. projects,
  361. environments,
  362. }: ChartProps) {
  363. const chartRef = useRef<ReactEchartsRef>(null);
  364. useEffect(() => {
  365. const echartsInstance = chartRef?.current?.getEchartsInstance();
  366. if (echartsInstance && !echartsInstance.group) {
  367. echartsInstance.group = DDM_CHART_GROUP;
  368. }
  369. }, []);
  370. const unit = series[0]?.unit;
  371. const seriesToShow = series.filter(s => !s.hidden);
  372. const formatters = {
  373. valueFormatter: (value: number) => {
  374. return formatMetricsUsingUnitAndOp(value, unit, operation);
  375. },
  376. nameFormatter: mri => getNameFromMRI(mri),
  377. };
  378. const chartProps = {
  379. forwardedRef: chartRef,
  380. isGroupedByDate: true,
  381. height: 300,
  382. colors: seriesToShow.map(s => s.color),
  383. grid: {top: 20, bottom: 20, left: 15, right: 25},
  384. tooltip: {
  385. formatter: (params, asyncTicket) => {
  386. const hoveredEchartElement = Array.from(document.querySelectorAll(':hover')).find(
  387. element => {
  388. return element.classList.contains('echarts-for-react');
  389. }
  390. );
  391. if (hoveredEchartElement === chartRef?.current?.ele) {
  392. return getFormatter(formatters)(params, asyncTicket);
  393. }
  394. return '';
  395. },
  396. axisPointer: {
  397. label: {show: true},
  398. },
  399. ...formatters,
  400. },
  401. yAxis: {
  402. axisLabel: {
  403. formatter: (value: number) => {
  404. return formatMetricsUsingUnitAndOp(value, unit, operation);
  405. },
  406. },
  407. },
  408. };
  409. return (
  410. <Fragment>
  411. <ChartZoom period={period} start={start} end={end} utc={utc}>
  412. {zoomRenderProps => (
  413. <ReleaseSeries
  414. utc={utc}
  415. period={period}
  416. start={zoomRenderProps.start!}
  417. end={zoomRenderProps.end!}
  418. projects={projects}
  419. environments={environments}
  420. preserveQueryParams
  421. >
  422. {({releaseSeries}) => {
  423. const releaseSeriesData = releaseSeries?.[0]?.markLine?.data ?? [];
  424. const selected =
  425. releaseSeriesData?.length >= RELEASE_LINES_THRESHOLD
  426. ? {[t('Releases')]: false}
  427. : {};
  428. const legend = releaseSeriesData?.length
  429. ? Legend({
  430. itemGap: 20,
  431. top: 0,
  432. right: 20,
  433. data: releaseSeries.map(s => s.seriesName),
  434. theme: theme as Theme,
  435. selected,
  436. })
  437. : undefined;
  438. const allProps = {
  439. series: [...seriesToShow, ...releaseSeries],
  440. legend,
  441. ...chartProps,
  442. ...zoomRenderProps,
  443. };
  444. return displayType === MetricDisplayType.LINE ? (
  445. <LineChart {...allProps} />
  446. ) : displayType === MetricDisplayType.AREA ? (
  447. <AreaChart {...allProps} />
  448. ) : (
  449. <BarChart stacked {...allProps} />
  450. );
  451. }}
  452. </ReleaseSeries>
  453. )}
  454. </ChartZoom>
  455. </Fragment>
  456. );
  457. }
  458. const minWidgetWidth = 400;
  459. const MetricWidgetPanel = styled(Panel)`
  460. padding-bottom: 0;
  461. margin-bottom: 0;
  462. min-width: ${minWidgetWidth};
  463. `;
  464. const StyledMetricWidgetBody = styled('div')`
  465. padding: ${space(1)};
  466. display: flex;
  467. flex-direction: column;
  468. justify-content: center;
  469. `;
  470. const StyledMetricDashboard = styled('div')`
  471. display: grid;
  472. grid-template-columns: repeat(3, minmax(${minWidgetWidth}px, 1fr));
  473. gap: ${space(2)};
  474. @media (max-width: ${props => props.theme.breakpoints.xxlarge}) {
  475. grid-template-columns: repeat(2, minmax(${minWidgetWidth}px, 1fr));
  476. }
  477. @media (max-width: ${props => props.theme.breakpoints.xlarge}) {
  478. grid-template-columns: repeat(1, minmax(${minWidgetWidth}px, 1fr));
  479. }
  480. `;
  481. const StyledSingleWidgetWrapper = styled('div')`
  482. display: flex;
  483. flex-direction: column;
  484. gap: ${space(2)};
  485. `;
  486. const AddWidgetPanel = styled(MetricWidgetPanel)`
  487. font-size: ${p => p.theme.fontSizeExtraLarge};
  488. padding: ${space(4)};
  489. display: flex;
  490. justify-content: center;
  491. align-items: center;
  492. &:hover {
  493. background-color: ${p => p.theme.backgroundSecondary};
  494. cursor: pointer;
  495. }
  496. `;
  497. export default MetricDashboard;