breakdownBar.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import {Fragment, useState} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import styled from '@emotion/styled';
  4. import {motion} from 'framer-motion';
  5. import {Tooltip} from 'sentry/components/tooltip';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import {percent} from 'sentry/utils';
  9. import {useQuery} from 'sentry/utils/queryClient';
  10. import {DatabaseDurationChart} from 'sentry/views/starfish/views/webServiceView/databaseDurationChart';
  11. import {HttpBreakdownChart} from 'sentry/views/starfish/views/webServiceView/httpBreakdownChart';
  12. import {
  13. DB_TIME_SPENT,
  14. OTHER_DOMAINS,
  15. TOP_DOMAINS,
  16. } from 'sentry/views/starfish/views/webServiceView/queries';
  17. const COLORS = ['#402A65', '#694D99', '#9A81C4', '#BBA6DF', '#EAE2F8'];
  18. const TOOLTIP_DELAY = 800;
  19. const HOST = 'http://localhost:8080';
  20. type ModuleSegment = {
  21. module: string;
  22. sum: number;
  23. };
  24. type Props = {
  25. segments: ModuleSegment[];
  26. title: string;
  27. };
  28. function FacetBreakdownBar({segments, title}: Props) {
  29. const [hoveredValue, setHoveredValue] = useState<ModuleSegment | null>(null);
  30. const [currentSegment, setCurrentSegment] = useState<
  31. ModuleSegment['module'] | undefined
  32. >(segments[0]?.module);
  33. const totalValues = segments.reduce((acc, segment) => acc + segment.sum, 0);
  34. const {isLoading: isDurationDataLoading, data: moduleDurationData} = useQuery({
  35. queryKey: ['topDomains'],
  36. queryFn: () => fetch(`${HOST}/?query=${TOP_DOMAINS}`).then(res => res.json()),
  37. retry: false,
  38. initialData: [],
  39. });
  40. const {isLoading: isOtherDurationDataLoading, data: moduleOtherDurationData} = useQuery(
  41. {
  42. queryKey: ['otherDomains'],
  43. queryFn: () => fetch(`${HOST}/?query=${OTHER_DOMAINS}`).then(res => res.json()),
  44. retry: false,
  45. initialData: [],
  46. }
  47. );
  48. const {isLoading: isDbDurationLoading, data: dbDurationData} = useQuery({
  49. queryKey: ['databaseDuration'],
  50. queryFn: () => fetch(`${HOST}/?query=${DB_TIME_SPENT}`).then(res => res.json()),
  51. retry: false,
  52. initialData: [],
  53. });
  54. function renderTitle() {
  55. return (
  56. <Title>
  57. <TitleType>{title}</TitleType>
  58. </Title>
  59. );
  60. }
  61. function renderSegments() {
  62. if (totalValues === 0) {
  63. return (
  64. <SegmentBar>
  65. <p>{t('No recent data.')}</p>
  66. </SegmentBar>
  67. );
  68. }
  69. return (
  70. <SegmentBar>
  71. {segments.map((value, index) => {
  72. const pct = percent(value.sum, totalValues);
  73. const pctLabel = Math.floor(pct);
  74. const segmentProps = {
  75. index,
  76. onClick: () => {
  77. setCurrentSegment(value.module);
  78. },
  79. };
  80. return (
  81. <div
  82. key={`segment-${value.module}`}
  83. style={{width: pct + '%'}}
  84. onMouseOver={() => {
  85. setHoveredValue(value);
  86. }}
  87. onMouseLeave={() => setHoveredValue(null)}
  88. >
  89. <Tooltip skipWrapper delay={TOOLTIP_DELAY} title={value.module}>
  90. <Segment
  91. aria-label={`${value.module} ${t('segment')}`}
  92. color={COLORS[index]}
  93. {...segmentProps}
  94. >
  95. {/* if the first segment is 6% or less, the label won't fit cleanly into the segment, so don't show the label */}
  96. {index === 0 && pctLabel > 6 ? `${pctLabel}%` : null}
  97. </Segment>
  98. </Tooltip>
  99. </div>
  100. );
  101. })}
  102. </SegmentBar>
  103. );
  104. }
  105. function renderLegend() {
  106. return (
  107. <LegendAnimateContainer expanded animate={{height: '100%', opacity: 1}}>
  108. <LegendContainer>
  109. {segments.map((segment, index) => {
  110. const pctLabel = Math.floor(percent(segment.sum, totalValues));
  111. const unfocus = !!hoveredValue && hoveredValue.module !== segment.module;
  112. const focus = hoveredValue?.module === segment.module;
  113. return (
  114. <li key={`segment-${segment.module}-${index}`}>
  115. <LegendRow
  116. onMouseOver={() => setHoveredValue(segment)}
  117. onMouseLeave={() => setHoveredValue(null)}
  118. onClick={() => setCurrentSegment(segment.module)}
  119. >
  120. <LegendDot color={COLORS[index]} focus={focus} />
  121. <LegendText unfocus={unfocus}>
  122. {segment.module ?? (
  123. <NotApplicableLabel>{t('n/a')}</NotApplicableLabel>
  124. )}
  125. </LegendText>
  126. {<LegendPercent>{`${pctLabel}%`}</LegendPercent>}
  127. </LegendRow>
  128. </li>
  129. );
  130. })}
  131. </LegendContainer>
  132. </LegendAnimateContainer>
  133. );
  134. }
  135. function renderChart(mod: string | undefined) {
  136. switch (mod) {
  137. case 'http':
  138. return (
  139. <HttpBreakdownChart
  140. isDurationDataLoading={isDurationDataLoading}
  141. isOtherDurationDataLoading={isOtherDurationDataLoading}
  142. moduleDurationData={moduleDurationData}
  143. moduleOtherDurationData={moduleOtherDurationData}
  144. />
  145. );
  146. case 'db':
  147. default:
  148. return (
  149. <DatabaseDurationChart
  150. isDbDurationLoading={isDbDurationLoading}
  151. dbDurationData={dbDurationData}
  152. />
  153. );
  154. }
  155. }
  156. return (
  157. <Fragment>
  158. <TagSummary>
  159. <details open aria-expanded onClick={e => e.preventDefault()}>
  160. <StyledSummary>
  161. <TagHeader>
  162. {renderTitle()}
  163. {renderSegments()}
  164. </TagHeader>
  165. </StyledSummary>
  166. {renderLegend()}
  167. </details>
  168. </TagSummary>
  169. {renderChart(currentSegment)}
  170. </Fragment>
  171. );
  172. }
  173. export default FacetBreakdownBar;
  174. const TagSummary = styled('div')`
  175. margin-bottom: ${space(2)};
  176. `;
  177. const TagHeader = styled('span')<{clickable?: boolean}>`
  178. ${p => (p.clickable ? 'cursor: pointer' : null)};
  179. `;
  180. const SegmentBar = styled('div')`
  181. display: flex;
  182. overflow: hidden;
  183. `;
  184. const Title = styled('div')`
  185. display: flex;
  186. font-size: ${p => p.theme.fontSizeMedium};
  187. justify-content: space-between;
  188. margin-bottom: ${space(1)};
  189. line-height: 1.1;
  190. `;
  191. const TitleType = styled('div')`
  192. flex: none;
  193. color: ${p => p.theme.textColor};
  194. font-weight: bold;
  195. font-size: ${p => p.theme.fontSizeMedium};
  196. margin-right: ${space(1)};
  197. align-self: center;
  198. `;
  199. const Segment = styled('span', {shouldForwardProp: isPropValid})<{color: string}>`
  200. &:hover {
  201. color: ${p => p.theme.white};
  202. }
  203. display: block;
  204. width: 100%;
  205. height: ${space(2)};
  206. color: ${p => p.theme.white};
  207. outline: none;
  208. background-color: ${p => p.color};
  209. text-align: right;
  210. font-size: ${p => p.theme.fontSizeExtraSmall};
  211. padding: 1px ${space(0.5)} 0 0;
  212. `;
  213. const LegendAnimateContainer = styled(motion.div, {
  214. shouldForwardProp: prop =>
  215. prop === 'animate' || (prop !== 'expanded' && isPropValid(prop)),
  216. })<{expanded: boolean}>`
  217. height: 0;
  218. opacity: 0;
  219. ${p => (!p.expanded ? 'overflow: hidden;' : '')}
  220. `;
  221. const LegendContainer = styled('ol')`
  222. list-style: none;
  223. padding: 0;
  224. margin: ${space(1)} 0;
  225. `;
  226. const LegendRow = styled('div')`
  227. display: flex;
  228. align-items: center;
  229. cursor: pointer;
  230. padding: ${space(0.5)} 0;
  231. `;
  232. const LegendDot = styled('span')<{color: string; focus: boolean}>`
  233. padding: 0;
  234. position: relative;
  235. width: 11px;
  236. height: 11px;
  237. text-indent: -9999em;
  238. display: inline-block;
  239. border-radius: 50%;
  240. flex-shrink: 0;
  241. background-color: ${p => p.color};
  242. &:after {
  243. content: '';
  244. border-radius: 50%;
  245. position: absolute;
  246. top: 0;
  247. left: 0;
  248. width: 100%;
  249. height: 100%;
  250. outline: ${p => p.theme.gray100} ${space(0.5)} solid;
  251. opacity: ${p => (p.focus ? '1' : '0')};
  252. transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  253. }
  254. `;
  255. const LegendText = styled('span')<{unfocus: boolean}>`
  256. font-size: ${p => p.theme.fontSizeSmall};
  257. margin-left: ${space(1)};
  258. overflow: hidden;
  259. white-space: nowrap;
  260. text-overflow: ellipsis;
  261. transition: color 0.3s;
  262. color: ${p => (p.unfocus ? p.theme.gray300 : p.theme.gray400)};
  263. `;
  264. const LegendPercent = styled('span')`
  265. font-size: ${p => p.theme.fontSizeSmall};
  266. margin-left: ${space(1)};
  267. color: ${p => p.theme.gray300};
  268. text-align: right;
  269. flex-grow: 1;
  270. `;
  271. const NotApplicableLabel = styled('span')`
  272. color: ${p => p.theme.gray300};
  273. `;
  274. const StyledSummary = styled('summary')`
  275. &::-webkit-details-marker {
  276. display: none;
  277. }
  278. `;