widget.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 {PageFilters} from 'sentry/types';
  17. import {
  18. defaultMetricDisplayType,
  19. MetricDisplayType,
  20. MetricsData,
  21. MetricsQuery,
  22. parseMRI,
  23. updateQuery,
  24. useMetricsDataZoom,
  25. } from 'sentry/utils/metrics';
  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 {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
  31. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  32. import {DEFAULT_SORT_STATE, MIN_WIDGET_WIDTH} from './constants';
  33. type SortState = {
  34. name: 'name' | 'avg' | 'min' | 'max' | 'sum';
  35. order: 'asc' | 'desc';
  36. };
  37. const emptyWidget = {
  38. mri: '',
  39. op: undefined,
  40. query: '',
  41. groupBy: [],
  42. sort: DEFAULT_SORT_STATE,
  43. };
  44. export type MetricWidgetDisplayConfig = {
  45. displayType: MetricDisplayType;
  46. onChange: (data: Partial<MetricWidgetProps>) => void;
  47. position: number;
  48. sort: SortState;
  49. focusedSeries?: string;
  50. powerUserMode?: boolean;
  51. showSummaryTable?: boolean;
  52. };
  53. export type MetricWidgetProps = Pick<MetricsQuery, 'mri' | 'op' | 'query' | 'groupBy'> &
  54. MetricWidgetDisplayConfig;
  55. export function useMetricWidgets() {
  56. const router = useRouter();
  57. const currentWidgets = JSON.parse(
  58. router.location.query.widgets ?? JSON.stringify([emptyWidget])
  59. );
  60. const widgets: MetricWidgetProps[] = currentWidgets.map(
  61. (widget: MetricWidgetProps, i) => {
  62. return {
  63. mri: widget.mri,
  64. op: widget.op,
  65. query: widget.query,
  66. groupBy: decodeList(widget.groupBy),
  67. displayType: widget.displayType ?? defaultMetricDisplayType,
  68. focusedSeries: widget.focusedSeries,
  69. showSummaryTable: widget.showSummaryTable ?? true, // temporary default
  70. position: widget.position ?? i,
  71. powerUserMode: widget.powerUserMode,
  72. sort: widget.sort ?? DEFAULT_SORT_STATE,
  73. };
  74. }
  75. );
  76. const onChange = (position: number, data: Partial<MetricWidgetProps>) => {
  77. currentWidgets[position] = {...currentWidgets[position], ...data};
  78. updateQuery(router, {
  79. widgets: JSON.stringify(currentWidgets),
  80. });
  81. };
  82. const addWidget = () => {
  83. currentWidgets.push({...emptyWidget, position: currentWidgets.length});
  84. updateQuery(router, {
  85. widgets: JSON.stringify(currentWidgets),
  86. });
  87. };
  88. return {
  89. widgets,
  90. onChange,
  91. addWidget,
  92. };
  93. }
  94. // TODO(ddm): reuse from types/metrics.tsx
  95. type Group = {
  96. by: Record<string, unknown>;
  97. series: Record<string, number[]>;
  98. totals: Record<string, number>;
  99. };
  100. export function MetricWidget({
  101. widget,
  102. datetime,
  103. projects,
  104. environments,
  105. }: {
  106. datetime: PageFilters['datetime'];
  107. environments: PageFilters['environments'];
  108. projects: PageFilters['projects'];
  109. widget: MetricWidgetProps;
  110. }) {
  111. return (
  112. <MetricWidgetPanel key={widget.position}>
  113. <PanelBody>
  114. <QueryBuilder
  115. metricsQuery={{
  116. mri: widget.mri,
  117. query: widget.query,
  118. op: widget.op,
  119. groupBy: widget.groupBy,
  120. }}
  121. projects={projects}
  122. displayType={widget.displayType}
  123. onChange={widget.onChange}
  124. powerUserMode={widget.powerUserMode}
  125. />
  126. {widget.mri ? (
  127. <MetricWidgetBody
  128. datetime={datetime}
  129. projects={projects}
  130. environments={environments}
  131. {...widget}
  132. />
  133. ) : (
  134. <StyledMetricWidgetBody>
  135. <EmptyMessage
  136. icon={<IconSearch size="xxl" />}
  137. title={t('Nothing to show!')}
  138. description={t('Choose a metric to display data.')}
  139. />
  140. </StyledMetricWidgetBody>
  141. )}
  142. </PanelBody>
  143. </MetricWidgetPanel>
  144. );
  145. }
  146. function MetricWidgetBody({
  147. onChange,
  148. displayType,
  149. focusedSeries,
  150. sort,
  151. ...metricsQuery
  152. }: MetricWidgetProps & PageFilters) {
  153. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  154. const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom({
  155. mri,
  156. op,
  157. query,
  158. groupBy,
  159. projects,
  160. environments,
  161. datetime,
  162. });
  163. const [dataToBeRendered, setDataToBeRendered] = useState<MetricsData | undefined>(
  164. undefined
  165. );
  166. const [hoveredLegend, setHoveredLegend] = useState('');
  167. useEffect(() => {
  168. if (data) {
  169. setDataToBeRendered(data);
  170. }
  171. }, [data]);
  172. const toggleSeriesVisibility = useCallback(
  173. (seriesName: string) => {
  174. setHoveredLegend('');
  175. onChange({
  176. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  177. });
  178. },
  179. [focusedSeries, onChange]
  180. );
  181. if (!dataToBeRendered || isError) {
  182. return (
  183. <StyledMetricWidgetBody>
  184. {isLoading && <LoadingIndicator />}
  185. {isError && (
  186. <Alert type="error">
  187. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  188. </Alert>
  189. )}
  190. </StyledMetricWidgetBody>
  191. );
  192. }
  193. const chartSeries = getChartSeries(dataToBeRendered, {
  194. focusedSeries,
  195. hoveredLegend,
  196. groupBy: metricsQuery.groupBy,
  197. });
  198. return (
  199. <StyledMetricWidgetBody>
  200. <TransparentLoadingMask visible={isLoading} />
  201. <MetricChart
  202. series={chartSeries}
  203. displayType={displayType}
  204. operation={metricsQuery.op}
  205. projects={metricsQuery.projects}
  206. environments={metricsQuery.environments}
  207. {...normalizeChartTimeParams(dataToBeRendered)}
  208. onZoom={onZoom}
  209. />
  210. {metricsQuery.showSummaryTable && (
  211. <SummaryTable
  212. series={chartSeries}
  213. onSortChange={newSort => {
  214. onChange({sort: newSort});
  215. }}
  216. sort={sort}
  217. operation={metricsQuery.op}
  218. onRowClick={toggleSeriesVisibility}
  219. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  220. />
  221. )}
  222. </StyledMetricWidgetBody>
  223. );
  224. }
  225. function getChartSeries(data: MetricsData, {focusedSeries, groupBy, hoveredLegend}) {
  226. // this assumes that all series have the same unit
  227. const parsed = parseMRI(Object.keys(data.groups[0]?.series ?? {})[0]);
  228. const unit = parsed?.unit ?? '';
  229. const series = data.groups.map(g => {
  230. return {
  231. values: Object.values(g.series)[0],
  232. name: getSeriesName(g, data.groups.length === 1, groupBy),
  233. transaction: g.by.transaction,
  234. release: g.by.release,
  235. };
  236. });
  237. const colors = theme.charts.getColorPalette(series.length);
  238. return series
  239. .sort((a, b) => a.name?.localeCompare(b.name))
  240. .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: moment(data.intervals[index]).valueOf(),
  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. }
  258. function getSeriesName(
  259. group: Group,
  260. isOnlyGroup = false,
  261. groupBy: MetricsQuery['groupBy']
  262. ) {
  263. if (isOnlyGroup && !groupBy?.length) {
  264. const mri = Object.keys(group.series)?.[0];
  265. const parsed = parseMRI(mri);
  266. return parsed?.name ?? '(none)';
  267. }
  268. return Object.entries(group.by)
  269. .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
  270. .join(', ');
  271. }
  272. function normalizeChartTimeParams(data: MetricsData) {
  273. const {
  274. start,
  275. end,
  276. utc: utcString,
  277. statsPeriod,
  278. } = normalizeDateTimeParams(data, {
  279. allowEmptyPeriod: true,
  280. allowAbsoluteDatetime: true,
  281. allowAbsolutePageDatetime: true,
  282. });
  283. const utc = utcString === 'true';
  284. if (start && end) {
  285. return utc
  286. ? {
  287. start: moment.utc(start).format(),
  288. end: moment.utc(end).format(),
  289. utc,
  290. }
  291. : {
  292. start: moment(start).utc().format(),
  293. end: moment(end).utc().format(),
  294. utc,
  295. };
  296. }
  297. return {
  298. period: statsPeriod ?? '90d',
  299. };
  300. }
  301. export type Series = {
  302. color: string;
  303. data: {name: number; value: number}[];
  304. seriesName: string;
  305. unit: string;
  306. hidden?: boolean;
  307. release?: string;
  308. transaction?: string;
  309. };
  310. const MetricWidgetPanel = styled(Panel)`
  311. padding-bottom: 0;
  312. margin-bottom: 0;
  313. min-width: ${MIN_WIDGET_WIDTH}px;
  314. `;
  315. const StyledMetricWidgetBody = styled('div')`
  316. padding: ${space(1)};
  317. display: flex;
  318. flex-direction: column;
  319. justify-content: center;
  320. `;