groupTagValues.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import {Fragment, useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import {useFetchIssueTag, useFetchIssueTagValues} from 'sentry/actionCreators/group';
  4. import {addMessage} from 'sentry/actionCreators/indicator';
  5. import {Button} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import DataExport, {ExportQueryType} from 'sentry/components/dataExport';
  8. import {DeviceName} from 'sentry/components/deviceName';
  9. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  10. import GlobalSelectionLink from 'sentry/components/globalSelectionLink';
  11. import UserBadge from 'sentry/components/idBadge/userBadge';
  12. import * as Layout from 'sentry/components/layouts/thirds';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import Link from 'sentry/components/links/link';
  15. import LoadingError from 'sentry/components/loadingError';
  16. import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
  17. import Pagination from 'sentry/components/pagination';
  18. import {PanelTable} from 'sentry/components/panels/panelTable';
  19. import TimeSince from 'sentry/components/timeSince';
  20. import {IconArrow, IconEllipsis, IconMail, IconOpen} from 'sentry/icons';
  21. import {t} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import type {Group, Project, SavedQueryVersions} from 'sentry/types';
  24. import {percent} from 'sentry/utils';
  25. import EventView from 'sentry/utils/discover/eventView';
  26. import {isUrl} from 'sentry/utils/string/isUrl';
  27. import {useLocation} from 'sentry/utils/useLocation';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. import {useParams} from 'sentry/utils/useParams';
  30. type RouteParams = {
  31. groupId: string;
  32. orgId: string;
  33. tagKey?: string;
  34. };
  35. type Props = {
  36. baseUrl: string;
  37. group: Group;
  38. environments?: string[];
  39. project?: Project;
  40. };
  41. const DEFAULT_SORT = 'count';
  42. function useTagQueries({
  43. group,
  44. tagKey,
  45. environments,
  46. sort,
  47. cursor,
  48. }: {
  49. group: Group;
  50. sort: string | string[];
  51. tagKey: string;
  52. cursor?: string;
  53. environments?: string[];
  54. }) {
  55. const organization = useOrganization();
  56. const {
  57. data: tagValueList,
  58. isLoading: tagValueListIsLoading,
  59. isError: tagValueListIsError,
  60. getResponseHeader,
  61. } = useFetchIssueTagValues({
  62. orgSlug: organization.slug,
  63. groupId: group.id,
  64. tagKey,
  65. environment: environments,
  66. sort,
  67. cursor,
  68. });
  69. const {data: tag, isError: tagIsError} = useFetchIssueTag({
  70. orgSlug: organization.slug,
  71. groupId: group.id,
  72. tagKey,
  73. });
  74. useEffect(() => {
  75. if (tagIsError) {
  76. addMessage(t('Failed to fetch total tag values'), 'error');
  77. }
  78. }, [tagIsError]);
  79. return {
  80. tagValueList,
  81. tag,
  82. isLoading: tagValueListIsLoading,
  83. isError: tagValueListIsError,
  84. pageLinks: getResponseHeader?.('Link'),
  85. };
  86. }
  87. function GroupTagValues({baseUrl, project, group, environments}: Props) {
  88. const organization = useOrganization();
  89. const location = useLocation();
  90. const {orgId, tagKey = ''} = useParams<RouteParams>();
  91. const {cursor, page: _page, ...currentQuery} = location.query;
  92. const title = tagKey === 'user' ? t('Affected Users') : tagKey;
  93. const sort = location.query.sort || DEFAULT_SORT;
  94. const sortArrow = <IconArrow color="gray300" size="xs" direction="down" />;
  95. const {tagValueList, tag, isLoading, isError, pageLinks} = useTagQueries({
  96. group,
  97. sort,
  98. tagKey,
  99. environments,
  100. cursor: typeof cursor === 'string' ? cursor : undefined,
  101. });
  102. const lastSeenColumnHeader = (
  103. <StyledSortLink
  104. to={{
  105. pathname: location.pathname,
  106. query: {
  107. ...currentQuery,
  108. sort: 'date',
  109. },
  110. }}
  111. >
  112. {t('Last Seen')} {sort === 'date' && sortArrow}
  113. </StyledSortLink>
  114. );
  115. const countColumnHeader = (
  116. <StyledSortLink
  117. to={{
  118. pathname: location.pathname,
  119. query: {
  120. ...currentQuery,
  121. sort: 'count',
  122. },
  123. }}
  124. >
  125. {t('Count')} {sort === 'count' && sortArrow}
  126. </StyledSortLink>
  127. );
  128. const renderResults = () => {
  129. if (isError) {
  130. return <StyledLoadingError message={t('There was an error loading tag details')} />;
  131. }
  132. if (isLoading) {
  133. return null;
  134. }
  135. const discoverFields = [
  136. 'title',
  137. 'release',
  138. 'environment',
  139. 'user.display',
  140. 'timestamp',
  141. ];
  142. const globalSelectionParams = extractSelectionParameters(location.query);
  143. return tagValueList?.map((tagValue, tagValueIdx) => {
  144. const pct = tag?.totalValues
  145. ? `${percent(tagValue.count, tag?.totalValues).toFixed(2)}%`
  146. : '--';
  147. const key = tagValue.key ?? tagKey;
  148. const issuesQuery = tagValue.query || `${key}:"${tagValue.value}"`;
  149. const discoverView = EventView.fromSavedQuery({
  150. id: undefined,
  151. name: key ?? '',
  152. fields: [
  153. ...(key !== undefined ? [key] : []),
  154. ...discoverFields.filter(field => field !== key),
  155. ],
  156. orderby: '-timestamp',
  157. query: `issue:${group.shortId} ${issuesQuery}`,
  158. projects: [Number(project?.id)],
  159. environment: environments,
  160. version: 2 as SavedQueryVersions,
  161. range: '90d',
  162. });
  163. const issuesPath = `/organizations/${orgId}/issues/`;
  164. return (
  165. <Fragment key={tagValueIdx}>
  166. <NameColumn>
  167. <NameWrapper data-test-id="group-tag-value">
  168. <GlobalSelectionLink
  169. to={{
  170. pathname: `${baseUrl}events/`,
  171. query: {query: issuesQuery},
  172. }}
  173. >
  174. {key === 'user' ? (
  175. <UserBadge
  176. user={{...tagValue, id: tagValue.identifier ?? ''}}
  177. avatarSize={20}
  178. hideEmail
  179. />
  180. ) : (
  181. <DeviceName value={tagValue.name} />
  182. )}
  183. </GlobalSelectionLink>
  184. </NameWrapper>
  185. {tagValue.email && (
  186. <StyledExternalLink
  187. href={`mailto:${tagValue.email}`}
  188. data-test-id="group-tag-mail"
  189. >
  190. <IconMail size="xs" color="gray300" />
  191. </StyledExternalLink>
  192. )}
  193. {isUrl(tagValue.value) && (
  194. <StyledExternalLink href={tagValue.value} data-test-id="group-tag-url">
  195. <IconOpen size="xs" color="gray300" />
  196. </StyledExternalLink>
  197. )}
  198. </NameColumn>
  199. <RightAlignColumn>{pct}</RightAlignColumn>
  200. <RightAlignColumn>{tagValue.count.toLocaleString()}</RightAlignColumn>
  201. <RightAlignColumn>
  202. <TimeSince date={tagValue.lastSeen} />
  203. </RightAlignColumn>
  204. <RightAlignColumn>
  205. <DropdownMenu
  206. size="sm"
  207. position="bottom-end"
  208. triggerProps={{
  209. size: 'xs',
  210. showChevron: false,
  211. icon: <IconEllipsis />,
  212. 'aria-label': t('More'),
  213. }}
  214. items={[
  215. {
  216. key: 'open-in-discover',
  217. label: t('Open in Discover'),
  218. to: discoverView.getResultsViewUrlTarget(orgId),
  219. hidden: !organization.features.includes('discover-basic'),
  220. },
  221. {
  222. key: 'search-issues',
  223. label: t('Search All Issues with Tag Value'),
  224. to: {
  225. pathname: issuesPath,
  226. query: {
  227. ...globalSelectionParams, // preserve page filter selections
  228. query: issuesQuery,
  229. },
  230. },
  231. },
  232. ]}
  233. />
  234. </RightAlignColumn>
  235. </Fragment>
  236. );
  237. });
  238. };
  239. return (
  240. <Layout.Body>
  241. <Layout.Main fullWidth>
  242. <TitleWrapper>
  243. <Title>{t('Tag Details')}</Title>
  244. <ButtonBar gap={1}>
  245. <Button
  246. size="sm"
  247. priority="default"
  248. href={`/${orgId}/${group.project.slug}/issues/${group.id}/tags/${tagKey}/export/`}
  249. >
  250. {t('Export Page to CSV')}
  251. </Button>
  252. <DataExport
  253. payload={{
  254. queryType: ExportQueryType.ISSUES_BY_TAG,
  255. queryInfo: {
  256. project: group.project.id,
  257. group: group.id,
  258. key: tagKey,
  259. },
  260. }}
  261. />
  262. </ButtonBar>
  263. </TitleWrapper>
  264. <StyledPanelTable
  265. isLoading={isLoading}
  266. isEmpty={!isError && tagValueList?.length === 0}
  267. headers={[
  268. title,
  269. <PercentColumnHeader key="percent">{t('Percent')}</PercentColumnHeader>,
  270. countColumnHeader,
  271. lastSeenColumnHeader,
  272. '',
  273. ]}
  274. emptyMessage={t('Sorry, the tags for this issue could not be found.')}
  275. emptyAction={
  276. environments?.length
  277. ? t('No tags were found for the currently selected environments')
  278. : null
  279. }
  280. >
  281. {renderResults()}
  282. </StyledPanelTable>
  283. <StyledPagination pageLinks={pageLinks} />
  284. </Layout.Main>
  285. </Layout.Body>
  286. );
  287. }
  288. export default GroupTagValues;
  289. const TitleWrapper = styled('div')`
  290. display: flex;
  291. flex-direction: row;
  292. flex-wrap: wrap;
  293. align-items: center;
  294. justify-content: space-between;
  295. margin-bottom: ${space(2)};
  296. `;
  297. const Title = styled('h3')`
  298. margin: 0;
  299. `;
  300. const StyledPanelTable = styled(PanelTable)`
  301. white-space: nowrap;
  302. font-size: ${p => p.theme.fontSizeMedium};
  303. overflow: auto;
  304. @media (min-width: ${p => p.theme.breakpoints.small}) {
  305. overflow: initial;
  306. }
  307. & > * {
  308. padding: ${space(1)} ${space(2)};
  309. }
  310. `;
  311. const StyledLoadingError = styled(LoadingError)`
  312. grid-column: 1 / -1;
  313. margin-bottom: ${space(4)};
  314. border-radius: 0;
  315. border-width: 1px 0;
  316. `;
  317. const PercentColumnHeader = styled('div')`
  318. text-align: right;
  319. `;
  320. const StyledSortLink = styled(Link)`
  321. text-align: right;
  322. color: inherit;
  323. :hover {
  324. color: inherit;
  325. }
  326. `;
  327. const StyledExternalLink = styled(ExternalLink)`
  328. margin-left: ${space(0.5)};
  329. `;
  330. const Column = styled('div')`
  331. display: flex;
  332. align-items: center;
  333. `;
  334. const NameColumn = styled(Column)`
  335. ${p => p.theme.overflowEllipsis};
  336. display: flex;
  337. min-width: 320px;
  338. `;
  339. const NameWrapper = styled('span')`
  340. ${p => p.theme.overflowEllipsis};
  341. width: auto;
  342. `;
  343. const RightAlignColumn = styled(Column)`
  344. justify-content: flex-end;
  345. `;
  346. const StyledPagination = styled(Pagination)`
  347. margin: 0;
  348. `;