profileDetails.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {PlatformIcon} from 'platformicons';
  4. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  5. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  6. import Button from 'sentry/components/button';
  7. import DateTime from 'sentry/components/dateTime';
  8. import Link from 'sentry/components/links/link';
  9. import {t} from 'sentry/locale';
  10. import OrganizationsStore from 'sentry/stores/organizationsStore';
  11. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  12. import space from 'sentry/styles/space';
  13. import {FlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/flamegraphStateProvider/reducers/flamegraphPreferences';
  14. import {useFlamegraphPreferences} from 'sentry/utils/profiling/flamegraph/hooks/useFlamegraphPreferences';
  15. import {
  16. useResizableDrawer,
  17. UseResizableDrawerOptions,
  18. } from 'sentry/utils/profiling/hooks/useResizableDrawer';
  19. import {ProfileGroup} from 'sentry/utils/profiling/profile/importProfile';
  20. import {makeFormatter} from 'sentry/utils/profiling/units/units';
  21. import useProjects from 'sentry/utils/useProjects';
  22. import {ProfilingDetailsFrameTabs, ProfilingDetailsListItem} from './frameStack';
  23. function renderValue(
  24. key: string,
  25. value: number | string | undefined,
  26. profileGroup: ProfileGroup
  27. ) {
  28. if (key === 'durationNS' && typeof value === 'number') {
  29. return nsFormatter(value);
  30. }
  31. if (key === 'threads') {
  32. return profileGroup.profiles.length;
  33. }
  34. if (key === 'received') {
  35. return <DateTime date={value} />;
  36. }
  37. if (value === undefined || value === '') {
  38. return t('ø');
  39. }
  40. return value;
  41. }
  42. interface ProfileDetailsProps {
  43. profileGroup: ProfileGroup;
  44. }
  45. export function ProfileDetails(props: ProfileDetailsProps) {
  46. const [detailsTab, setDetailsTab] = useState<'device' | 'transaction'>('transaction');
  47. const organizations = useLegacyStore(OrganizationsStore);
  48. const {projects} = useProjects();
  49. const onDeviceTabClick = useCallback(() => {
  50. setDetailsTab('device');
  51. }, []);
  52. const onTransactionTabClick = useCallback(() => {
  53. setDetailsTab('transaction');
  54. }, []);
  55. const flamegraphPreferences = useFlamegraphPreferences();
  56. const isResizableDetailsBar =
  57. flamegraphPreferences.layout === 'table left' ||
  58. flamegraphPreferences.layout === 'table right';
  59. const detailsBarRef = useRef<HTMLDivElement>(null);
  60. const resizableOptions: UseResizableDrawerOptions = useMemo(() => {
  61. const initialDimensions: [number, number] | [undefined, number] =
  62. flamegraphPreferences.layout === 'table bottom' ? [260, 200] : [0, 200];
  63. const onResize = (
  64. newDimensions: [number, number],
  65. maybeOldDimensions?: [number, number]
  66. ) => {
  67. if (!detailsBarRef.current) {
  68. return;
  69. }
  70. if (
  71. flamegraphPreferences.layout === 'table left' ||
  72. flamegraphPreferences.layout === 'table right'
  73. ) {
  74. detailsBarRef.current.style.width = `100%`;
  75. detailsBarRef.current.style.height =
  76. (maybeOldDimensions?.[1] ?? newDimensions[1]) + 'px';
  77. } else {
  78. detailsBarRef.current.style.height = ``;
  79. detailsBarRef.current.style.width = ``;
  80. }
  81. };
  82. return {
  83. initialDimensions,
  84. onResize,
  85. direction:
  86. flamegraphPreferences.layout === 'table bottom' ? 'horizontal-ltr' : 'vertical',
  87. min: [0, 26],
  88. };
  89. }, [flamegraphPreferences.layout]);
  90. const {onMouseDown} = useResizableDrawer(resizableOptions);
  91. const organization = organizations.find(
  92. o => o.id === String(props.profileGroup.metadata.organizationID)
  93. );
  94. return (
  95. <ProfileDetailsBar ref={detailsBarRef} layout={flamegraphPreferences.layout}>
  96. <ProfilingDetailsFrameTabs>
  97. <ProfilingDetailsListItem
  98. size="sm"
  99. className={detailsTab === 'transaction' ? 'active' : undefined}
  100. >
  101. <Button
  102. data-title={t('Transaction')}
  103. priority="link"
  104. size="zero"
  105. onClick={onTransactionTabClick}
  106. >
  107. {t('Transaction')}
  108. </Button>
  109. </ProfilingDetailsListItem>
  110. <ProfilingDetailsListItem
  111. size="sm"
  112. className={detailsTab === 'device' ? 'active' : undefined}
  113. >
  114. <Button
  115. data-title={t('Device')}
  116. priority="link"
  117. size="zero"
  118. onClick={onDeviceTabClick}
  119. >
  120. {t('Device')}
  121. </Button>
  122. </ProfilingDetailsListItem>
  123. <ProfilingDetailsListItem
  124. style={{
  125. flex: '1 1 100%',
  126. cursor: isResizableDetailsBar ? 'ns-resize' : undefined,
  127. }}
  128. onMouseDown={isResizableDetailsBar ? onMouseDown : undefined}
  129. />
  130. </ProfilingDetailsFrameTabs>
  131. {detailsTab === 'device' ? (
  132. <DetailsContainer>
  133. {Object.entries(DEVICE_DETAILS_KEY).map(([label, key]) => {
  134. const value = props.profileGroup.metadata[key];
  135. return (
  136. <DetailsRow key={key}>
  137. <strong>{label}:</strong>
  138. <span>{renderValue(key, value, props.profileGroup)}</span>
  139. </DetailsRow>
  140. );
  141. })}
  142. </DetailsContainer>
  143. ) : (
  144. <DetailsContainer>
  145. {Object.entries(PROFILE_DETAILS_KEY).map(([label, key]) => {
  146. const value = props.profileGroup.metadata[key];
  147. if (key === 'organizationID') {
  148. if (organization) {
  149. return (
  150. <DetailsRow key={key}>
  151. <strong>{label}:</strong>
  152. <Link to={`/organizations/${organization.slug}/projects/`}>
  153. <span>
  154. <OrganizationAvatar size={12} organization={organization} />{' '}
  155. {organization.name}
  156. </span>
  157. </Link>
  158. </DetailsRow>
  159. );
  160. }
  161. }
  162. if (key === 'projectID') {
  163. const project = projects.find(p => p.id === String(value));
  164. if (project && organization) {
  165. return (
  166. <DetailsRow key={key}>
  167. <strong>{label}:</strong>
  168. <Link
  169. to={`/organizations/${organization.slug}/projects/${project.slug}/?project=${project.id}`}
  170. >
  171. <FlexRow>
  172. <ProjectAvatar project={project} size={12} /> {project.slug}
  173. </FlexRow>
  174. </Link>
  175. </DetailsRow>
  176. );
  177. }
  178. }
  179. return (
  180. <DetailsRow key={key}>
  181. <strong>{label}:</strong>
  182. <span>
  183. {key === 'platform' ? (
  184. <Fragment>
  185. <PlatformIcon size={12} platform={value ?? 'unknown'} />{' '}
  186. </Fragment>
  187. ) : null}
  188. {renderValue(key, value, props.profileGroup)}
  189. </span>
  190. </DetailsRow>
  191. );
  192. })}
  193. <DetailsRow />
  194. </DetailsContainer>
  195. )}
  196. </ProfileDetailsBar>
  197. );
  198. }
  199. const nsFormatter = makeFormatter('nanoseconds');
  200. const PROFILE_DETAILS_KEY: Record<string, string> = {
  201. [t('transaction')]: 'transactionName',
  202. [t('received at')]: 'received',
  203. [t('organization')]: 'organizationID',
  204. [t('project')]: 'projectID',
  205. [t('platform')]: 'platform',
  206. [t('environment')]: 'environment',
  207. [t('version')]: 'version',
  208. [t('duration')]: 'durationNS',
  209. [t('threads')]: 'threads',
  210. };
  211. const DEVICE_DETAILS_KEY: Record<string, string> = {
  212. [t('model')]: 'deviceModel',
  213. [t('manufacturer')]: 'deviceManufacturer',
  214. [t('classification')]: 'deviceClassification',
  215. [t('os')]: 'deviceOSName',
  216. [t('os version')]: 'deviceOSVersion',
  217. [t('locale')]: 'deviceLocale',
  218. };
  219. // ProjectAvatar is contained in a div
  220. const FlexRow = styled('span')`
  221. display: inline-flex;
  222. align-items: center;
  223. > div {
  224. margin-right: ${space(0.5)};
  225. }
  226. `;
  227. const DetailsRow = styled('div')`
  228. white-space: nowrap;
  229. text-overflow: ellipsis;
  230. overflow: hidden;
  231. display: flex;
  232. align-items: center;
  233. font-size: ${p => p.theme.fontSizeSmall};
  234. > span {
  235. min-width: 0;
  236. overflow: hidden;
  237. text-overflow: ellipsis;
  238. }
  239. > strong {
  240. margin-right: ${space(0.5)};
  241. }
  242. `;
  243. const DetailsContainer = styled('div')`
  244. padding: ${space(1)};
  245. margin: 0;
  246. overflow: auto;
  247. position: absolute;
  248. left: 0;
  249. top: 24px;
  250. width: 100%;
  251. height: calc(100% - 24px);
  252. `;
  253. const ProfileDetailsBar = styled('div')<{layout: FlamegraphPreferences['layout']}>`
  254. width: ${p =>
  255. p.layout === 'table left' || p.layout === 'table right' ? '100%' : '260px'};
  256. height: ${p =>
  257. p.layout === 'table left' || p.layout === 'table right' ? '220px' : '100%'};
  258. border-left: 1px solid ${p => p.theme.border};
  259. background: ${p => p.theme.background};
  260. grid-area: details;
  261. position: relative;
  262. > ul:first-child {
  263. border-bottom: 1px solid ${p => p.theme.border};
  264. }
  265. `;