metricChart.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. import {Fragment, PureComponent} from 'react';
  2. import {browserHistory, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import color from 'color';
  5. import type {LineSeriesOption} from 'echarts';
  6. import capitalize from 'lodash/capitalize';
  7. import moment from 'moment';
  8. import momentTimezone from 'moment-timezone';
  9. import {Client} from 'sentry/api';
  10. import Feature from 'sentry/components/acl/feature';
  11. import {OnDemandMetricAlert} from 'sentry/components/alerts/onDemandMetricAlert';
  12. import {Button} from 'sentry/components/button';
  13. import {AreaChart, AreaChartSeries} from 'sentry/components/charts/areaChart';
  14. import ChartZoom from 'sentry/components/charts/chartZoom';
  15. import MarkArea from 'sentry/components/charts/components/markArea';
  16. import MarkLine from 'sentry/components/charts/components/markLine';
  17. import EventsRequest from 'sentry/components/charts/eventsRequest';
  18. import LineSeries from 'sentry/components/charts/series/lineSeries';
  19. import SessionsRequest from 'sentry/components/charts/sessionsRequest';
  20. import {
  21. ChartControls,
  22. HeaderTitleLegend,
  23. InlineContainer,
  24. SectionHeading,
  25. SectionValue,
  26. } from 'sentry/components/charts/styles';
  27. import {isEmptySeries} from 'sentry/components/charts/utils';
  28. import CircleIndicator from 'sentry/components/circleIndicator';
  29. import {
  30. parseStatsPeriod,
  31. StatsPeriodType,
  32. } from 'sentry/components/organizations/pageFilters/parse';
  33. import Panel from 'sentry/components/panels/panel';
  34. import PanelBody from 'sentry/components/panels/panelBody';
  35. import Placeholder from 'sentry/components/placeholder';
  36. import {Tooltip} from 'sentry/components/tooltip';
  37. import Truncate from 'sentry/components/truncate';
  38. import {IconCheckmark, IconClock, IconFire, IconWarning} from 'sentry/icons';
  39. import {t} from 'sentry/locale';
  40. import ConfigStore from 'sentry/stores/configStore';
  41. import {space} from 'sentry/styles/space';
  42. import {DateString, Organization, Project} from 'sentry/types';
  43. import {ReactEchartsRef, Series} from 'sentry/types/echarts';
  44. import {getUtcDateString} from 'sentry/utils/dates';
  45. import {getDuration} from 'sentry/utils/formatters';
  46. import getDynamicText from 'sentry/utils/getDynamicText';
  47. import {MINUTES_THRESHOLD_TO_DISPLAY_SECONDS} from 'sentry/utils/sessions';
  48. import theme from 'sentry/utils/theme';
  49. import toArray from 'sentry/utils/toArray';
  50. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  51. // eslint-disable-next-line no-restricted-imports
  52. import withSentryRouter from 'sentry/utils/withSentryRouter';
  53. import {COMPARISON_DELTA_OPTIONS} from 'sentry/views/alerts/rules/metric/constants';
  54. import {makeDefaultCta} from 'sentry/views/alerts/rules/metric/metricRulePresets';
  55. import {
  56. AlertRuleTriggerType,
  57. MetricRule,
  58. TimePeriod,
  59. } from 'sentry/views/alerts/rules/metric/types';
  60. import {getChangeStatus} from 'sentry/views/alerts/utils/getChangeStatus';
  61. import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
  62. import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
  63. import {Incident} from '../../../types';
  64. import {
  65. alertDetailsLink,
  66. alertTooltipValueFormatter,
  67. isSessionAggregate,
  68. SESSION_AGGREGATE_TO_FIELD,
  69. } from '../../../utils';
  70. import {getMetricDatasetQueryExtras} from '../utils/getMetricDatasetQueryExtras';
  71. import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
  72. import {TimePeriodType} from './constants';
  73. import {
  74. getMetricAlertChartOption,
  75. transformSessionResponseToSeries,
  76. } from './metricChartOption';
  77. type Props = WithRouterProps & {
  78. api: Client;
  79. filter: string[] | null;
  80. interval: string;
  81. organization: Organization;
  82. project: Project;
  83. query: string;
  84. rule: MetricRule;
  85. timePeriod: TimePeriodType;
  86. incidents?: Incident[];
  87. isOnDemandAlert?: boolean;
  88. selectedIncident?: Incident | null;
  89. };
  90. type State = {
  91. height: number;
  92. width: number;
  93. };
  94. function formatTooltipDate(date: moment.MomentInput, format: string): string {
  95. const {
  96. options: {timezone},
  97. } = ConfigStore.get('user');
  98. return momentTimezone.tz(date, timezone).format(format);
  99. }
  100. function getRuleChangeSeries(
  101. rule: MetricRule,
  102. data: AreaChartSeries[]
  103. ): LineSeriesOption[] {
  104. const {dateModified} = rule;
  105. if (!data.length || !data[0].data.length || !dateModified) {
  106. return [];
  107. }
  108. const seriesData = data[0].data;
  109. const seriesStart = new Date(seriesData[0].name).getTime();
  110. const ruleChanged = new Date(dateModified).getTime();
  111. if (ruleChanged < seriesStart) {
  112. return [];
  113. }
  114. return [
  115. {
  116. type: 'line',
  117. markLine: MarkLine({
  118. silent: true,
  119. animation: false,
  120. lineStyle: {color: theme.gray200, type: 'solid', width: 1},
  121. data: [{xAxis: ruleChanged}],
  122. label: {
  123. show: false,
  124. },
  125. }),
  126. markArea: MarkArea({
  127. silent: true,
  128. itemStyle: {
  129. color: color(theme.gray100).alpha(0.42).rgb().string(),
  130. },
  131. data: [[{xAxis: seriesStart}, {xAxis: ruleChanged}]],
  132. }),
  133. data: [],
  134. },
  135. ];
  136. }
  137. class MetricChart extends PureComponent<Props, State> {
  138. state = {
  139. width: -1,
  140. height: -1,
  141. };
  142. ref: null | ReactEchartsRef = null;
  143. /**
  144. * Syncs component state with the chart's width/heights
  145. */
  146. updateDimensions = () => {
  147. const chartRef = this.ref?.getEchartsInstance?.();
  148. if (!chartRef) {
  149. return;
  150. }
  151. const width = chartRef.getWidth();
  152. const height = chartRef.getHeight();
  153. if (width !== this.state.width || height !== this.state.height) {
  154. this.setState({
  155. width,
  156. height,
  157. });
  158. }
  159. };
  160. handleRef = (ref: ReactEchartsRef): void => {
  161. if (ref && !this.ref) {
  162. this.ref = ref;
  163. this.updateDimensions();
  164. }
  165. if (!ref) {
  166. this.ref = null;
  167. }
  168. };
  169. handleZoom = (start: DateString, end: DateString) => {
  170. const {location} = this.props;
  171. browserHistory.push({
  172. pathname: location.pathname,
  173. query: {
  174. start,
  175. end,
  176. },
  177. });
  178. };
  179. renderChartActions(
  180. totalDuration: number,
  181. criticalDuration: number,
  182. warningDuration: number,
  183. waitingForDataDuration: number
  184. ) {
  185. const {rule, organization, project, timePeriod, query} = this.props;
  186. const {buttonText, ...props} = makeDefaultCta({
  187. orgSlug: organization.slug,
  188. projects: [project],
  189. rule,
  190. timePeriod,
  191. query,
  192. });
  193. const resolvedPercent =
  194. (100 *
  195. Math.max(
  196. totalDuration - waitingForDataDuration - criticalDuration - warningDuration,
  197. 0
  198. )) /
  199. totalDuration;
  200. const criticalPercent = 100 * Math.min(criticalDuration / totalDuration, 1);
  201. const warningPercent = 100 * Math.min(warningDuration / totalDuration, 1);
  202. const waitingForDataPercent =
  203. 100 *
  204. Math.min(
  205. (waitingForDataDuration - criticalDuration - warningDuration) / totalDuration,
  206. 1
  207. );
  208. return (
  209. <StyledChartControls>
  210. <StyledInlineContainer>
  211. <Fragment>
  212. <SectionHeading>{t('Summary')}</SectionHeading>
  213. <StyledSectionValue>
  214. <ValueItem>
  215. <IconCheckmark color="successText" isCircled />
  216. {resolvedPercent ? resolvedPercent.toFixed(2) : 0}%
  217. </ValueItem>
  218. <ValueItem>
  219. <IconWarning color="warningText" />
  220. {warningPercent ? warningPercent.toFixed(2) : 0}%
  221. </ValueItem>
  222. <ValueItem>
  223. <IconFire color="errorText" />
  224. {criticalPercent ? criticalPercent.toFixed(2) : 0}%
  225. </ValueItem>
  226. {waitingForDataPercent > 0 && (
  227. <StyledTooltip
  228. underlineColor="gray200"
  229. showUnderline
  230. title={t(
  231. 'The time spent waiting for metrics matching the filters used.'
  232. )}
  233. >
  234. <ValueItem>
  235. <IconClock />
  236. {waitingForDataPercent.toFixed(2)}%
  237. </ValueItem>
  238. </StyledTooltip>
  239. )}
  240. </StyledSectionValue>
  241. </Fragment>
  242. </StyledInlineContainer>
  243. {!isSessionAggregate(rule.aggregate) && (
  244. <Feature features={['discover-basic']}>
  245. <Button size="sm" {...props}>
  246. {buttonText}
  247. </Button>
  248. </Feature>
  249. )}
  250. </StyledChartControls>
  251. );
  252. }
  253. renderChart(
  254. loading: boolean,
  255. timeseriesData?: Series[],
  256. minutesThresholdToDisplaySeconds?: number,
  257. comparisonTimeseriesData?: Series[]
  258. ) {
  259. const {
  260. router,
  261. selectedIncident,
  262. interval,
  263. filter,
  264. incidents,
  265. rule,
  266. organization,
  267. timePeriod: {start, end},
  268. } = this.props;
  269. const {width} = this.state;
  270. const {dateModified, timeWindow} = rule;
  271. if (loading || !timeseriesData) {
  272. return this.renderEmpty();
  273. }
  274. const handleIncidentClick = (incident: Incident) => {
  275. router.push(
  276. normalizeUrl({
  277. pathname: alertDetailsLink(organization, incident),
  278. query: {alert: incident.identifier},
  279. })
  280. );
  281. };
  282. const {
  283. criticalDuration,
  284. warningDuration,
  285. totalDuration,
  286. waitingForDataDuration,
  287. chartOption,
  288. } = getMetricAlertChartOption({
  289. timeseriesData,
  290. rule,
  291. incidents,
  292. selectedIncident,
  293. isOnDemandMetricAlert: this.props.isOnDemandAlert,
  294. handleIncidentClick,
  295. });
  296. const comparisonSeriesName = capitalize(
  297. COMPARISON_DELTA_OPTIONS.find(({value}) => value === rule.comparisonDelta)?.label ||
  298. ''
  299. );
  300. const additionalSeries: LineSeriesOption[] = [
  301. ...(comparisonTimeseriesData || []).map(({data: _data, ...otherSeriesProps}) =>
  302. LineSeries({
  303. name: comparisonSeriesName,
  304. data: _data.map(({name, value}) => [name, value]),
  305. lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
  306. itemStyle: {color: theme.gray200},
  307. animation: false,
  308. animationThreshold: 1,
  309. animationDuration: 0,
  310. ...otherSeriesProps,
  311. })
  312. ),
  313. ...getRuleChangeSeries(rule, timeseriesData),
  314. ];
  315. const queryFilter =
  316. filter?.join(' ') + t(' over ') + getDuration(rule.timeWindow * 60);
  317. const percentOfWidth =
  318. width >= 1151
  319. ? 15
  320. : width < 1151 && width >= 700
  321. ? 14
  322. : width < 700 && width >= 515
  323. ? 13
  324. : width < 515 && width >= 300
  325. ? 12
  326. : 8;
  327. const truncateWidth = (percentOfWidth / 100) * width;
  328. return (
  329. <ChartPanel>
  330. <StyledPanelBody withPadding>
  331. <ChartHeader>
  332. <HeaderTitleLegend>
  333. {AlertWizardAlertNames[getAlertTypeFromAggregateDataset(rule)]}
  334. </HeaderTitleLegend>
  335. </ChartHeader>
  336. <ChartFilters>
  337. <StyledCircleIndicator size={8} />
  338. <Filters>{rule.aggregate}</Filters>
  339. <Truncate value={queryFilter ?? ''} maxLength={truncateWidth} />
  340. </ChartFilters>
  341. {getDynamicText({
  342. value: (
  343. <ChartZoom
  344. router={router}
  345. start={start}
  346. end={end}
  347. onZoom={zoomArgs => this.handleZoom(zoomArgs.start, zoomArgs.end)}
  348. onFinished={() => {
  349. // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
  350. // any graphics related to the triggers (e.g. the threshold areas + boundaries)
  351. this.updateDimensions();
  352. }}
  353. >
  354. {zoomRenderProps => (
  355. <AreaChart
  356. {...zoomRenderProps}
  357. {...chartOption}
  358. showTimeInTooltip
  359. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  360. forwardedRef={this.handleRef}
  361. additionalSeries={additionalSeries}
  362. tooltip={{
  363. formatter: seriesParams => {
  364. // seriesParams can be object instead of array
  365. const pointSeries = toArray(seriesParams);
  366. const {marker, data: pointData, seriesName} = pointSeries[0];
  367. const [pointX, pointY] = pointData as [number, number];
  368. const pointYFormatted = alertTooltipValueFormatter(
  369. pointY,
  370. seriesName ?? '',
  371. rule.aggregate
  372. );
  373. const isModified =
  374. dateModified && pointX <= new Date(dateModified).getTime();
  375. const startTime = formatTooltipDate(moment(pointX), 'MMM D LT');
  376. const {period, periodLength} = parseStatsPeriod(interval) ?? {
  377. periodLength: 'm',
  378. period: `${timeWindow}`,
  379. };
  380. const endTime = formatTooltipDate(
  381. moment(pointX).add(
  382. parseInt(period, 10),
  383. periodLength as StatsPeriodType
  384. ),
  385. 'MMM D LT'
  386. );
  387. const comparisonSeries =
  388. pointSeries.length > 1
  389. ? pointSeries.find(
  390. ({seriesName: _sn}) => _sn === comparisonSeriesName
  391. )
  392. : undefined;
  393. const comparisonPointY = comparisonSeries?.data[1] as
  394. | number
  395. | undefined;
  396. const comparisonPointYFormatted =
  397. comparisonPointY !== undefined
  398. ? alertTooltipValueFormatter(
  399. comparisonPointY,
  400. seriesName ?? '',
  401. rule.aggregate
  402. )
  403. : undefined;
  404. const changePercentage =
  405. comparisonPointY === undefined
  406. ? NaN
  407. : ((pointY - comparisonPointY) * 100) / comparisonPointY;
  408. const changeStatus = getChangeStatus(
  409. changePercentage,
  410. rule.thresholdType,
  411. rule.triggers
  412. );
  413. const changeStatusColor =
  414. changeStatus === AlertRuleTriggerType.CRITICAL
  415. ? theme.red300
  416. : changeStatus === AlertRuleTriggerType.WARNING
  417. ? theme.yellow300
  418. : theme.green300;
  419. return [
  420. `<div class="tooltip-series">`,
  421. isModified &&
  422. `<div><span class="tooltip-label"><strong>${t(
  423. 'Alert Rule Modified'
  424. )}</strong></span></div>`,
  425. `<div><span class="tooltip-label">${marker} <strong>${seriesName}</strong></span>${pointYFormatted}</div>`,
  426. comparisonSeries &&
  427. `<div><span class="tooltip-label">${comparisonSeries.marker} <strong>${comparisonSeriesName}</strong></span>${comparisonPointYFormatted}</div>`,
  428. `</div>`,
  429. `<div class="tooltip-footer">`,
  430. `<span>${startTime} &mdash; ${endTime}</span>`,
  431. comparisonPointY !== undefined &&
  432. Math.abs(changePercentage) !== Infinity &&
  433. !isNaN(changePercentage) &&
  434. `<span style="color:${changeStatusColor};margin-left:10px;">${
  435. Math.sign(changePercentage) === 1 ? '+' : '-'
  436. }${Math.abs(changePercentage).toFixed(2)}%</span>`,
  437. `</div>`,
  438. '<div class="tooltip-arrow"></div>',
  439. ]
  440. .filter(e => e)
  441. .join('');
  442. },
  443. }}
  444. />
  445. )}
  446. </ChartZoom>
  447. ),
  448. fixed: <Placeholder height="200px" testId="skeleton-ui" />,
  449. })}
  450. </StyledPanelBody>
  451. {this.renderChartActions(
  452. totalDuration,
  453. criticalDuration,
  454. warningDuration,
  455. waitingForDataDuration
  456. )}
  457. </ChartPanel>
  458. );
  459. }
  460. renderEmptyOnDemandAlert(timeseriesData: Series[] = [], loading?: boolean) {
  461. if (loading || !this.props.isOnDemandAlert || !isEmptySeries(timeseriesData[0])) {
  462. return null;
  463. }
  464. return (
  465. <OnDemandMetricAlert
  466. dismissable
  467. message={t(
  468. 'This alert lacks historical data due to filters for which we don’t routinely extract metrics.'
  469. )}
  470. />
  471. );
  472. }
  473. renderEmpty(placeholderText = '') {
  474. return (
  475. <ChartPanel>
  476. <PanelBody withPadding>
  477. <TriggerChartPlaceholder>{placeholderText}</TriggerChartPlaceholder>
  478. </PanelBody>
  479. </ChartPanel>
  480. );
  481. }
  482. render() {
  483. const {
  484. api,
  485. rule,
  486. organization,
  487. timePeriod,
  488. project,
  489. interval,
  490. query,
  491. location,
  492. isOnDemandAlert,
  493. } = this.props;
  494. const {aggregate, timeWindow, environment, dataset} = rule;
  495. // Fix for 7 days * 1m interval being over the max number of results from events api
  496. // 10k events is the current max
  497. if (
  498. timePeriod.usingPeriod &&
  499. timePeriod.period === TimePeriod.SEVEN_DAYS &&
  500. interval === '1m'
  501. ) {
  502. timePeriod.start = getUtcDateString(
  503. // -5 minutes provides a small cushion for rounding up minutes. This might be able to be smaller
  504. moment(moment.utc(timePeriod.end).subtract(10000 - 5, 'minutes'))
  505. );
  506. }
  507. // If the chart duration isn't as long as the rollup duration the events-stats
  508. // endpoint will return an invalid timeseriesData dataset
  509. const viableStartDate = getUtcDateString(
  510. moment.min(
  511. moment.utc(timePeriod.start),
  512. moment.utc(timePeriod.end).subtract(timeWindow, 'minutes')
  513. )
  514. );
  515. const viableEndDate = getUtcDateString(
  516. moment.utc(timePeriod.end).add(timeWindow, 'minutes')
  517. );
  518. const queryExtras = getMetricDatasetQueryExtras({
  519. organization,
  520. location,
  521. dataset,
  522. newAlertOrQuery: false,
  523. });
  524. return isCrashFreeAlert(dataset) ? (
  525. <SessionsRequest
  526. api={api}
  527. organization={organization}
  528. project={project.id ? [Number(project.id)] : []}
  529. environment={environment ? [environment] : undefined}
  530. start={viableStartDate}
  531. end={viableEndDate}
  532. query={query}
  533. interval={interval}
  534. field={SESSION_AGGREGATE_TO_FIELD[aggregate]}
  535. groupBy={['session.status']}
  536. >
  537. {({loading, response}) =>
  538. this.renderChart(
  539. loading,
  540. transformSessionResponseToSeries(response, rule),
  541. MINUTES_THRESHOLD_TO_DISPLAY_SECONDS
  542. )
  543. }
  544. </SessionsRequest>
  545. ) : (
  546. <EventsRequest
  547. api={api}
  548. organization={organization}
  549. query={query}
  550. environment={environment ? [environment] : undefined}
  551. project={project.id ? [Number(project.id)] : []}
  552. interval={interval}
  553. comparisonDelta={rule.comparisonDelta ? rule.comparisonDelta * 60 : undefined}
  554. start={viableStartDate}
  555. end={viableEndDate}
  556. yAxis={aggregate}
  557. includePrevious={false}
  558. currentSeriesNames={[aggregate]}
  559. partial={false}
  560. queryExtras={queryExtras}
  561. referrer="api.alerts.alert-rule-chart"
  562. useOnDemandMetrics={isOnDemandAlert}
  563. >
  564. {({loading, timeseriesData, comparisonTimeseriesData}) => (
  565. <Fragment>
  566. {this.renderEmptyOnDemandAlert(timeseriesData, loading)}
  567. {this.renderChart(
  568. loading,
  569. timeseriesData,
  570. undefined,
  571. comparisonTimeseriesData
  572. )}
  573. </Fragment>
  574. )}
  575. </EventsRequest>
  576. );
  577. }
  578. }
  579. export default withSentryRouter(MetricChart);
  580. const ChartPanel = styled(Panel)`
  581. margin-top: ${space(2)};
  582. `;
  583. const ChartHeader = styled('div')`
  584. margin-bottom: ${space(3)};
  585. `;
  586. const StyledChartControls = styled(ChartControls)`
  587. display: flex;
  588. justify-content: space-between;
  589. flex-wrap: wrap;
  590. `;
  591. const StyledInlineContainer = styled(InlineContainer)`
  592. grid-auto-flow: column;
  593. grid-column-gap: ${space(1)};
  594. `;
  595. const StyledCircleIndicator = styled(CircleIndicator)`
  596. background: ${p => p.theme.formText};
  597. height: ${space(1)};
  598. margin-right: ${space(0.5)};
  599. `;
  600. const ChartFilters = styled('div')`
  601. font-size: ${p => p.theme.fontSizeSmall};
  602. font-family: ${p => p.theme.text.family};
  603. color: ${p => p.theme.textColor};
  604. display: inline-grid;
  605. grid-template-columns: repeat(3, max-content);
  606. align-items: center;
  607. `;
  608. const Filters = styled('span')`
  609. margin-right: ${space(1)};
  610. `;
  611. const StyledSectionValue = styled(SectionValue)`
  612. display: grid;
  613. grid-template-columns: repeat(4, auto);
  614. gap: ${space(1.5)};
  615. margin: 0 0 0 ${space(1.5)};
  616. `;
  617. const ValueItem = styled('div')`
  618. display: grid;
  619. grid-template-columns: repeat(2, auto);
  620. gap: ${space(0.5)};
  621. align-items: center;
  622. font-variant-numeric: tabular-nums;
  623. text-underline-offset: ${space(4)};
  624. `;
  625. /* Override padding to make chart appear centered */
  626. const StyledPanelBody = styled(PanelBody)`
  627. padding-right: 6px;
  628. `;
  629. const TriggerChartPlaceholder = styled(Placeholder)`
  630. height: 200px;
  631. text-align: center;
  632. padding: ${space(3)};
  633. `;
  634. const StyledTooltip = styled(Tooltip)`
  635. text-underline-offset: ${space(0.5)} !important;
  636. `;