metricWidget.tsx 14 KB

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