widget.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import {memo, useCallback, useEffect, useMemo, 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, MRI, PageFilters} from 'sentry/types';
  17. import {
  18. getSeriesName,
  19. MetricDisplayType,
  20. MetricWidgetQueryParams,
  21. } from 'sentry/utils/metrics';
  22. import {parseMRI} from 'sentry/utils/metrics/mri';
  23. import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
  24. import theme from 'sentry/utils/theme';
  25. import {MetricChart} from 'sentry/views/ddm/chart';
  26. import {MetricWidgetContextMenu} from 'sentry/views/ddm/contextMenu';
  27. import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
  28. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  29. import {MIN_WIDGET_WIDTH} from './constants';
  30. export const MetricWidget = memo(
  31. ({
  32. widget,
  33. datetime,
  34. projects,
  35. environments,
  36. index,
  37. isSelected,
  38. onSelect,
  39. onChange,
  40. numberOfSiblings,
  41. }: {
  42. datetime: PageFilters['datetime'];
  43. environments: PageFilters['environments'];
  44. index: number;
  45. isSelected: boolean;
  46. numberOfSiblings: number;
  47. onChange: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
  48. onSelect: (index: number) => void;
  49. projects: PageFilters['projects'];
  50. widget: MetricWidgetQueryParams;
  51. }) => {
  52. const [isEdit, setIsEdit] = useState(true);
  53. const handleChange = useCallback(
  54. (data: Partial<MetricWidgetQueryParams>) => {
  55. onChange(index, data);
  56. },
  57. [index, onChange]
  58. );
  59. useEffect(() => {
  60. // exit the edit mode when the focus is lost
  61. // it would work without it (because we do edit && focus) but when you focus again, we want the edit mode to be turned off by default
  62. if (!isSelected) {
  63. setIsEdit(false);
  64. }
  65. }, [isSelected]);
  66. const metricsQuery = useMemo(
  67. () => ({
  68. mri: widget.mri,
  69. query: widget.query,
  70. op: widget.op,
  71. groupBy: widget.groupBy,
  72. projects,
  73. datetime,
  74. environments,
  75. title: widget.title,
  76. }),
  77. [widget, projects, datetime, environments]
  78. );
  79. const shouldDisplayEditControls = (isEdit && isSelected) || !metricsQuery.mri;
  80. return (
  81. <MetricWidgetPanel
  82. // show the selection border only if we have more widgets than one
  83. isHighlighted={isSelected && !!numberOfSiblings}
  84. isHighlightable={!!numberOfSiblings}
  85. onClick={() => onSelect(index)}
  86. >
  87. <PanelBody>
  88. <MetricWidgetHeader>
  89. <QueryBuilder
  90. metricsQuery={metricsQuery}
  91. projects={projects}
  92. displayType={widget.displayType}
  93. onChange={handleChange}
  94. powerUserMode={widget.powerUserMode}
  95. isEdit={shouldDisplayEditControls}
  96. />
  97. <MetricWidgetContextMenu
  98. widgetIndex={index}
  99. metricsQuery={metricsQuery}
  100. displayType={widget.displayType}
  101. isEdit={shouldDisplayEditControls}
  102. onEdit={() => setIsEdit(true)}
  103. />
  104. </MetricWidgetHeader>
  105. {widget.mri ? (
  106. <MetricWidgetBody
  107. datetime={datetime}
  108. projects={projects}
  109. environments={environments}
  110. onChange={handleChange}
  111. {...widget}
  112. />
  113. ) : (
  114. <StyledMetricWidgetBody>
  115. <EmptyMessage
  116. icon={<IconSearch size="xxl" />}
  117. title={t('Nothing to show!')}
  118. description={t('Choose a metric to display data.')}
  119. />
  120. </StyledMetricWidgetBody>
  121. )}
  122. </PanelBody>
  123. </MetricWidgetPanel>
  124. );
  125. }
  126. );
  127. const MetricWidgetHeader = styled('div')`
  128. display: flex;
  129. justify-content: space-between;
  130. `;
  131. interface MetricWidgetProps extends MetricWidgetQueryParams {
  132. onChange: (data: Partial<MetricWidgetQueryParams>) => void;
  133. }
  134. const MetricWidgetBody = memo(
  135. ({
  136. onChange,
  137. displayType,
  138. focusedSeries,
  139. sort,
  140. ...metricsQuery
  141. }: MetricWidgetProps & PageFilters) => {
  142. const {mri, op, query, groupBy, projects, environments, datetime} = metricsQuery;
  143. const {data, isLoading, isError, error, onZoom} = useMetricsDataZoom(
  144. {
  145. mri,
  146. op,
  147. query,
  148. groupBy,
  149. projects,
  150. environments,
  151. datetime,
  152. },
  153. {fidelity: displayType === MetricDisplayType.BAR ? 'low' : 'high'}
  154. );
  155. const [dataToBeRendered, setDataToBeRendered] = useState<
  156. MetricsApiResponse | undefined
  157. >(undefined);
  158. const [hoveredLegend, setHoveredLegend] = useState('');
  159. useEffect(() => {
  160. if (data) {
  161. setDataToBeRendered(data);
  162. }
  163. }, [data]);
  164. const toggleSeriesVisibility = useCallback(
  165. (seriesName: string) => {
  166. setHoveredLegend('');
  167. onChange({
  168. focusedSeries: focusedSeries === seriesName ? undefined : seriesName,
  169. });
  170. },
  171. [focusedSeries, onChange]
  172. );
  173. if (!dataToBeRendered || isError) {
  174. return (
  175. <StyledMetricWidgetBody>
  176. {isLoading && <LoadingIndicator />}
  177. {isError && (
  178. <Alert type="error">
  179. {error?.responseJSON?.detail || t('Error while fetching metrics data')}
  180. </Alert>
  181. )}
  182. </StyledMetricWidgetBody>
  183. );
  184. }
  185. if (dataToBeRendered.groups.length === 0) {
  186. return (
  187. <StyledMetricWidgetBody>
  188. <EmptyMessage
  189. icon={<IconSearch size="xxl" />}
  190. title={t('No results')}
  191. description={t('No results found for the given query')}
  192. />
  193. </StyledMetricWidgetBody>
  194. );
  195. }
  196. const chartSeries = getChartSeries(dataToBeRendered, {
  197. mri,
  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. {...normalizeChartTimeParams(dataToBeRendered)}
  211. onZoom={onZoom}
  212. />
  213. {metricsQuery.showSummaryTable && (
  214. <SummaryTable
  215. series={chartSeries}
  216. onSortChange={newSort => {
  217. onChange({sort: newSort});
  218. }}
  219. sort={sort}
  220. operation={metricsQuery.op}
  221. onRowClick={toggleSeriesVisibility}
  222. setHoveredLegend={focusedSeries ? undefined : setHoveredLegend}
  223. />
  224. )}
  225. </StyledMetricWidgetBody>
  226. );
  227. }
  228. );
  229. export function getChartSeries(
  230. data: MetricsApiResponse,
  231. {
  232. mri,
  233. focusedSeries,
  234. groupBy,
  235. hoveredLegend,
  236. displayType,
  237. }: {
  238. displayType: MetricDisplayType;
  239. mri: MRI;
  240. focusedSeries?: string;
  241. groupBy?: string[];
  242. hoveredLegend?: string;
  243. }
  244. ) {
  245. // this assumes that all series have the same unit
  246. const parsed = parseMRI(mri);
  247. const unit = parsed?.unit ?? '';
  248. const series = data.groups.map(g => {
  249. return {
  250. values: Object.values(g.series)[0],
  251. name: getSeriesName(g, data.groups.length === 1, groupBy),
  252. transaction: g.by.transaction,
  253. release: g.by.release,
  254. };
  255. });
  256. const colors = getChartColorPalette(displayType, series.length);
  257. return sortSeries(series, displayType).map((item, i) => ({
  258. seriesName: item.name,
  259. unit,
  260. color: colorFn(colors[i % colors.length])
  261. .alpha(hoveredLegend && hoveredLegend !== item.name ? 0.1 : 1)
  262. .string(),
  263. hidden: focusedSeries && focusedSeries !== item.name,
  264. data: item.values.map((value, index) => ({
  265. name: moment(data.intervals[index]).valueOf(),
  266. value,
  267. })),
  268. transaction: item.transaction as string | undefined,
  269. release: item.release as string | undefined,
  270. emphasis: {
  271. focus: 'series',
  272. } as LineSeriesOption['emphasis'],
  273. })) as Series[];
  274. }
  275. function sortSeries(
  276. series: {
  277. name: string;
  278. release: string;
  279. transaction: string;
  280. values: (number | null)[];
  281. }[],
  282. displayType: MetricDisplayType
  283. ) {
  284. const sorted = series
  285. // we need to sort the series by their values so that the colors in area chart do not overlap
  286. // for now we are only sorting by the first value, but we might need to sort by the sum of all values
  287. .sort((a, b) => {
  288. return Number(a.values?.[0]) > Number(b.values?.[0]) ? -1 : 1;
  289. });
  290. if (displayType === MetricDisplayType.BAR) {
  291. return sorted.toReversed();
  292. }
  293. return sorted;
  294. }
  295. function getChartColorPalette(displayType: MetricDisplayType, length: number) {
  296. // We do length - 2 to be aligned with the colors in other parts of the app (copy-pasta)
  297. // We use Math.max to avoid numbers < -1 as then `getColorPalette` returns undefined (not typesafe because of array access)
  298. const palette = theme.charts.getColorPalette(Math.max(length - 2, -1));
  299. if (displayType === MetricDisplayType.BAR) {
  300. return palette;
  301. }
  302. return palette.toReversed();
  303. }
  304. function normalizeChartTimeParams(data: MetricsApiResponse) {
  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: number; value: number}[];
  336. seriesName: string;
  337. unit: string;
  338. hidden?: boolean;
  339. release?: string;
  340. transaction?: string;
  341. };
  342. const MetricWidgetPanel = styled(Panel)<{
  343. isHighlightable: boolean;
  344. isHighlighted: boolean;
  345. }>`
  346. padding-bottom: 0;
  347. margin-bottom: 0;
  348. min-width: ${MIN_WIDGET_WIDTH}px;
  349. position: relative;
  350. transition: box-shadow 0.2s ease;
  351. ${p =>
  352. p.isHighlightable &&
  353. `
  354. &:focus,
  355. &:hover {
  356. box-shadow: 0px 0px 0px 3px
  357. ${p.isHighlighted ? p.theme.purple200 : 'rgba(209, 202, 216, 0.2)'};
  358. }
  359. `}
  360. ${p =>
  361. p.isHighlighted &&
  362. `
  363. box-shadow: 0px 0px 0px 3px ${p.theme.purple200};
  364. border-color: transparent;
  365. `}
  366. `;
  367. const StyledMetricWidgetBody = styled('div')`
  368. padding: ${space(1)};
  369. display: flex;
  370. flex-direction: column;
  371. justify-content: center;
  372. `;