widget.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import {useCallback, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import colorFn from 'color';
  4. import type {LineSeriesOption} from 'echarts';
  5. import moment from 'moment';
  6. import Alert from 'sentry/components/alert';
  7. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  8. import EmptyMessage from 'sentry/components/emptyMessage';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelBody from 'sentry/components/panels/panelBody';
  13. import {IconSearch} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {MetricsApiResponse, PageFilters} from 'sentry/types';
  17. import {
  18. defaultMetricDisplayType,
  19. getSeriesName,
  20. MetricDisplayType,
  21. MetricWidgetQueryParams,
  22. updateQuery,
  23. } from 'sentry/utils/metrics';
  24. import {parseMRI} from 'sentry/utils/metrics/mri';
  25. import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  26. import {decodeList} from 'sentry/utils/queryString';
  27. import theme from 'sentry/utils/theme';
  28. import useRouter from 'sentry/utils/useRouter';
  29. import {MetricChart} from 'sentry/views/ddm/chart';
  30. import {CodeLocations} from 'sentry/views/ddm/codeLocations';
  31. import {MetricWidgetContextMenu} from 'sentry/views/ddm/contextMenu';
  32. import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
  33. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  34. import {DEFAULT_SORT_STATE, MIN_WIDGET_WIDTH} from './constants';
  35. const emptyWidget = {
  36. mri: '',
  37. op: undefined,
  38. query: '',
  39. groupBy: [],
  40. sort: DEFAULT_SORT_STATE,
  41. };
  42. export interface MetricWidgetProps extends MetricWidgetQueryParams {
  43. onChange: (data: Partial<MetricWidgetProps>) => void;
  44. position: number;
  45. }
  46. export function useMetricWidgets() {
  47. const router = useRouter();
  48. const currentWidgets = JSON.parse(
  49. router.location.query.widgets ?? JSON.stringify([emptyWidget])
  50. );
  51. const widgets: MetricWidgetProps[] = currentWidgets.map(
  52. (widget: MetricWidgetQueryParams, i) => {
  53. return {
  54. mri: widget.mri,
  55. op: widget.op,
  56. query: widget.query,
  57. groupBy: decodeList(widget.groupBy),
  58. displayType: widget.displayType ?? defaultMetricDisplayType,
  59. focusedSeries: widget.focusedSeries,
  60. showSummaryTable: widget.showSummaryTable ?? true, // temporary default
  61. position: widget.position ?? i,
  62. powerUserMode: widget.powerUserMode,
  63. sort: widget.sort ?? DEFAULT_SORT_STATE,
  64. };
  65. }
  66. );
  67. const onChange = (position: number, data: Partial<MetricWidgetQueryParams>) => {
  68. currentWidgets[position] = {...currentWidgets[position], ...data};
  69. updateQuery(router, {
  70. widgets: JSON.stringify(currentWidgets),
  71. });
  72. };
  73. const addWidget = () => {
  74. currentWidgets.push({...emptyWidget, position: currentWidgets.length});
  75. updateQuery(router, {
  76. widgets: JSON.stringify(currentWidgets),
  77. });
  78. };
  79. return {
  80. widgets,
  81. onChange,
  82. addWidget,
  83. };
  84. }
  85. export function MetricWidget({
  86. widget,
  87. datetime,
  88. projects,
  89. environments,
  90. }: {
  91. datetime: PageFilters['datetime'];
  92. environments: PageFilters['environments'];
  93. projects: PageFilters['projects'];
  94. widget: MetricWidgetProps;
  95. }) {
  96. return (
  97. <MetricWidgetPanel key={widget.position}>
  98. <PanelBody>
  99. <MetricWidgetHeader>
  100. <QueryBuilder
  101. metricsQuery={{
  102. mri: widget.mri,
  103. query: widget.query,
  104. op: widget.op,
  105. groupBy: widget.groupBy,
  106. }}
  107. projects={projects}
  108. displayType={widget.displayType}
  109. onChange={widget.onChange}
  110. powerUserMode={widget.powerUserMode}
  111. />
  112. <MetricWidgetContextMenu
  113. metricsQuery={{
  114. mri: widget.mri,
  115. query: widget.query,
  116. op: widget.op,
  117. groupBy: widget.groupBy,
  118. projects,
  119. datetime,
  120. environments,
  121. }}
  122. displayType={widget.displayType}
  123. />
  124. </MetricWidgetHeader>
  125. {widget.mri ? (
  126. <MetricWidgetBody
  127. datetime={datetime}
  128. projects={projects}
  129. environments={environments}
  130. {...widget}
  131. />
  132. ) : (
  133. <StyledMetricWidgetBody>
  134. <EmptyMessage
  135. icon={<IconSearch size="xxl" />}
  136. title={t('Nothing to show!')}
  137. description={t('Choose a metric to display data.')}
  138. />
  139. </StyledMetricWidgetBody>
  140. )}
  141. </PanelBody>
  142. </MetricWidgetPanel>
  143. );
  144. }
  145. const MetricWidgetHeader = styled('div')`
  146. display: flex;
  147. justify-content: space-between;
  148. margin-bottom: ${space(1)};
  149. `;
  150. function MetricWidgetBody({
  151. onChange,
  152. displayType,
  153. focusedSeries,
  154. sort,
  155. ...metricsQuery
  156. }: MetricWidgetProps & PageFilters) {
  157. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  158. const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom({
  159. mri,
  160. op,
  161. query,
  162. groupBy,
  163. projects,
  164. environments,
  165. datetime,
  166. });
  167. const [dataToBeRendered, setDataToBeRendered] = useState<
  168. MetricsApiResponse | undefined
  169. >(undefined);
  170. const [hoveredLegend, setHoveredLegend] = useState('');
  171. useEffect(() => {
  172. if (data) {
  173. setDataToBeRendered(data);
  174. }
  175. }, [data]);
  176. const toggleSeriesVisibility = useCallback(
  177. (seriesName: string) => {
  178. setHoveredLegend('');
  179. onChange({
  180. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  181. });
  182. },
  183. [focusedSeries, onChange]
  184. );
  185. if (!dataToBeRendered || isError) {
  186. return (
  187. <StyledMetricWidgetBody>
  188. {isLoading && <LoadingIndicator />}
  189. {isError && (
  190. <Alert type="error">
  191. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  192. </Alert>
  193. )}
  194. </StyledMetricWidgetBody>
  195. );
  196. }
  197. const chartSeries = getChartSeries(dataToBeRendered, {
  198. focusedSeries,
  199. hoveredLegend,
  200. groupBy: metricsQuery.groupBy,
  201. displayType,
  202. });
  203. return (
  204. <StyledMetricWidgetBody>
  205. <TransparentLoadingMask visible={isLoading} />
  206. <MetricChart
  207. series={chartSeries}
  208. displayType={displayType}
  209. operation={metricsQuery.op}
  210. projects={metricsQuery.projects}
  211. environments={metricsQuery.environments}
  212. {...normalizeChartTimeParams(dataToBeRendered)}
  213. onZoom={onZoom}
  214. />
  215. {metricsQuery.showSummaryTable && (
  216. <SummaryTable
  217. series={chartSeries}
  218. onSortChange={newSort => {
  219. onChange({sort: newSort});
  220. }}
  221. sort={sort}
  222. operation={metricsQuery.op}
  223. onRowClick={toggleSeriesVisibility}
  224. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  225. />
  226. )}
  227. <CodeLocations mri={metricsQuery.mri} projects={projects} />
  228. </StyledMetricWidgetBody>
  229. );
  230. }
  231. function getChartSeries(
  232. data: MetricsApiResponse,
  233. {focusedSeries, groupBy, hoveredLegend, displayType}
  234. ) {
  235. // this assumes that all series have the same unit
  236. const parsed = parseMRI(Object.keys(data.groups[0]?.series ?? {})[0]);
  237. const unit = parsed?.unit ?? '';
  238. const series = data.groups.map(g => {
  239. return {
  240. values: Object.values(g.series)[0],
  241. name: getSeriesName(g, data.groups.length === 1, groupBy),
  242. transaction: g.by.transaction,
  243. release: g.by.release,
  244. };
  245. });
  246. const colors = getChartColorPalette(displayType, series.length);
  247. return sortSeries(series, displayType).map((item, i) => ({
  248. seriesName: item.name,
  249. unit,
  250. color: colorFn(colors[i])
  251. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  252. .string(),
  253. hidden: focusedSeries && focusedSeries !== item.name,
  254. data: item.values.map((value, index) => ({
  255. name: moment(data.intervals[index]).valueOf(),
  256. value,
  257. })),
  258. transaction: item.transaction as string | undefined,
  259. release: item.release as string | undefined,
  260. emphasis: {
  261. focus: 'series',
  262. } as LineSeriesOption['emphasis'],
  263. })) as Series[];
  264. }
  265. function sortSeries(
  266. series: {
  267. name: string;
  268. release: string;
  269. transaction: string;
  270. values: (number | null)[];
  271. }[],
  272. displayType: MetricDisplayType
  273. ) {
  274. const sorted = series
  275. // we need to sort the series by their values so that the colors in area chart do not overlap
  276. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  277. .sort((a, b) => {
  278. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  279. });
  280. if (displayType === MetricDisplayType.BAR) {
  281. return sorted.toReversed();
  282. }
  283. return sorted;
  284. }
  285. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  286. const palette = theme.charts.getColorPalette(length - 2);
  287. if (displayType === MetricDisplayType.BAR) {
  288. return palette;
  289. }
  290. return palette.toReversed();
  291. }
  292. function normalizeChartTimeParams(data: MetricsApiResponse) {
  293. const {
  294. start,
  295. end,
  296. utc: utcString,
  297. statsPeriod,
  298. } = normalizeDateTimeParams(data, {
  299. allowEmptyPeriod: true,
  300. allowAbsoluteDatetime: true,
  301. allowAbsolutePageDatetime: true,
  302. });
  303. const utc = utcString === 'true';
  304. if (start && end) {
  305. return utc
  306. ? {
  307. start: moment.utc(start).format(),
  308. end: moment.utc(end).format(),
  309. utc,
  310. }
  311. : {
  312. start: moment(start).utc().format(),
  313. end: moment(end).utc().format(),
  314. utc,
  315. };
  316. }
  317. return {
  318. period: statsPeriod ?? '90d',
  319. };
  320. }
  321. export type Series = {
  322. color: string;
  323. data: {name: number; value: number}[];
  324. seriesName: string;
  325. unit: string;
  326. hidden?: boolean;
  327. release?: string;
  328. transaction?: string;
  329. };
  330. const MetricWidgetPanel = styled(Panel)`
  331. padding-bottom: 0;
  332. margin-bottom: 0;
  333. min-width: ${MIN_WIDGET_WIDTH}px;
  334. `;
  335. const StyledMetricWidgetBody = styled('div')`
  336. padding: ${space(1)};
  337. display: flex;
  338. flex-direction: column;
  339. justify-content: center;
  340. `;