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