teamIssuesAge.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import moment from 'moment-timezone';
  5. import {BarChart} from 'sentry/components/charts/barChart';
  6. import Count from 'sentry/components/count';
  7. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  8. import Link from 'sentry/components/links/link';
  9. import LoadingError from 'sentry/components/loadingError';
  10. import {PanelTable} from 'sentry/components/panels/panelTable';
  11. import Placeholder from 'sentry/components/placeholder';
  12. import TimeSince from 'sentry/components/timeSince';
  13. import {IconArrow} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Group, Organization} from 'sentry/types';
  17. import {getTitle} from 'sentry/utils/events';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. interface TeamIssuesAgeProps {
  20. organization: Organization;
  21. teamSlug: string;
  22. }
  23. /**
  24. * takes "< 1 hour" and returns a datetime of 1 hour ago
  25. */
  26. function parseBucket(bucket: string): number {
  27. if (bucket === '> 1 year') {
  28. return moment().subtract(1, 'y').subtract(1, 'd').valueOf();
  29. }
  30. const [_, num, unit] = bucket.split(' ');
  31. return moment()
  32. .subtract(num, unit as any)
  33. .valueOf();
  34. }
  35. const bucketLabels = {
  36. '< 1 hour': t('1 hour'),
  37. '< 4 hour': t('4 hours'),
  38. '< 12 hour': t('12 hours'),
  39. '< 1 day': t('1 day'),
  40. '< 1 week': t('1 week'),
  41. '< 4 week': t('1 month'),
  42. '< 24 week': t('6 months'),
  43. '< 1 year': t('1 year'),
  44. '> 1 year': t('> 1 year'),
  45. };
  46. function TeamIssuesAge({organization, teamSlug}: TeamIssuesAgeProps) {
  47. const {
  48. data: oldestIssues,
  49. isLoading: isOldestIssuesLoading,
  50. isError: isOldestIssuesError,
  51. refetch: refetchOldestIssues,
  52. } = useApiQuery<Group[]>(
  53. [
  54. `/teams/${organization.slug}/${teamSlug}/issues/old/`,
  55. {
  56. query: {
  57. limit: 7,
  58. },
  59. },
  60. ],
  61. {staleTime: 5000}
  62. );
  63. const {
  64. data: unresolvedIssueAge,
  65. isLoading: isUnresolvedIssueAgeLoading,
  66. isError: isUnresolvedIssueAgeError,
  67. refetch: refetchUnresolvedIssueAge,
  68. } = useApiQuery<Record<string, number>>(
  69. [`/teams/${organization.slug}/${teamSlug}/unresolved-issue-age/`],
  70. {staleTime: 5000}
  71. );
  72. const isLoading = isOldestIssuesLoading || isUnresolvedIssueAgeLoading;
  73. if (isOldestIssuesError || isUnresolvedIssueAgeError) {
  74. return (
  75. <LoadingError
  76. onRetry={() => {
  77. refetchOldestIssues();
  78. refetchUnresolvedIssueAge();
  79. }}
  80. />
  81. );
  82. }
  83. const seriesData = Object.entries(unresolvedIssueAge ?? {})
  84. .map(([bucket, value]) => ({
  85. name: bucket,
  86. value,
  87. }))
  88. .sort((a, b) => parseBucket(b.name) - parseBucket(a.name));
  89. return (
  90. <div>
  91. <ChartWrapper>
  92. {isLoading && <Placeholder height="200px" />}
  93. {!isLoading && (
  94. <BarChart
  95. style={{height: 190}}
  96. legend={{right: 3, top: 0}}
  97. yAxis={{minInterval: 1}}
  98. xAxis={{
  99. type: 'category',
  100. min: 0,
  101. axisLabel: {
  102. showMaxLabel: true,
  103. showMinLabel: true,
  104. formatter: (bucket: string) => {
  105. return bucketLabels[bucket] ?? bucket;
  106. },
  107. },
  108. }}
  109. series={[
  110. {
  111. seriesName: t('Unresolved Issues'),
  112. silent: true,
  113. data: seriesData,
  114. barCategoryGap: '5%',
  115. },
  116. ]}
  117. />
  118. )}
  119. </ChartWrapper>
  120. <StyledPanelTable
  121. isEmpty={!oldestIssues || oldestIssues?.length === 0}
  122. emptyMessage={t('No unresolved issues for this team’s projects')}
  123. headers={[
  124. t('Oldest Issues'),
  125. <RightAligned key="events">{t('Events')}</RightAligned>,
  126. <RightAligned key="users">{t('Users')}</RightAligned>,
  127. <RightAligned key="age">
  128. {t('Age')} <IconArrow direction="down" size="xs" color="gray300" />
  129. </RightAligned>,
  130. ]}
  131. isLoading={isLoading}
  132. >
  133. {oldestIssues?.map(issue => {
  134. const {title} = getTitle(issue, organization?.features, false);
  135. return (
  136. <Fragment key={issue.id}>
  137. <ProjectTitleContainer>
  138. <ShadowlessProjectBadge
  139. disableLink
  140. hideName
  141. avatarSize={18}
  142. project={issue.project}
  143. />
  144. <TitleOverflow>
  145. <Link
  146. to={{
  147. pathname: `/organizations/${organization.slug}/issues/${issue.id}/`,
  148. }}
  149. >
  150. {title}
  151. </Link>
  152. </TitleOverflow>
  153. </ProjectTitleContainer>
  154. <RightAligned>
  155. <Count value={issue.count} />
  156. </RightAligned>
  157. <RightAligned>
  158. <Count value={issue.userCount} />
  159. </RightAligned>
  160. <RightAligned>
  161. <TimeSince date={issue.firstSeen} />
  162. </RightAligned>
  163. </Fragment>
  164. );
  165. })}
  166. </StyledPanelTable>
  167. </div>
  168. );
  169. }
  170. export default TeamIssuesAge;
  171. const ChartWrapper = styled('div')`
  172. padding: ${space(2)} ${space(2)} 0 ${space(2)};
  173. border-bottom: 1px solid ${p => p.theme.border};
  174. `;
  175. const StyledPanelTable = styled(PanelTable)`
  176. grid-template-columns: 1fr 0.15fr 0.15fr 0.25fr;
  177. white-space: nowrap;
  178. margin-bottom: 0;
  179. border: 0;
  180. font-size: ${p => p.theme.fontSizeMedium};
  181. box-shadow: unset;
  182. > * {
  183. padding: ${space(1)} ${space(2)};
  184. }
  185. ${p =>
  186. p.isEmpty &&
  187. css`
  188. & > div:last-child {
  189. padding: 48px ${space(2)};
  190. }
  191. `}
  192. `;
  193. const RightAligned = styled('span')`
  194. display: flex;
  195. align-items: center;
  196. justify-content: flex-end;
  197. `;
  198. const ProjectTitleContainer = styled('div')`
  199. ${p => p.theme.overflowEllipsis};
  200. display: flex;
  201. align-items: center;
  202. `;
  203. const TitleOverflow = styled('div')`
  204. ${p => p.theme.overflowEllipsis};
  205. `;
  206. const ShadowlessProjectBadge = styled(ProjectBadge)`
  207. display: inline-flex;
  208. align-items: center;
  209. margin-right: ${space(1)};
  210. * > img {
  211. box-shadow: none;
  212. }
  213. `;