widget.tsx 11 KB

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