teamStability.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import {Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import round from 'lodash/round';
  6. import AsyncComponent from 'sentry/components/asyncComponent';
  7. import Button from 'sentry/components/button';
  8. import MiniBarChart from 'sentry/components/charts/miniBarChart';
  9. import SessionsRequest from 'sentry/components/charts/sessionsRequest';
  10. import {DateTimeObject} from 'sentry/components/charts/utils';
  11. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  12. import PanelTable from 'sentry/components/panels/panelTable';
  13. import Placeholder from 'sentry/components/placeholder';
  14. import {IconArrow} from 'sentry/icons';
  15. import {t, tct} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import {
  18. Organization,
  19. Project,
  20. SessionApiResponse,
  21. SessionFieldWithOperation,
  22. SessionStatus,
  23. } from 'sentry/types';
  24. import {formatFloat} from 'sentry/utils/formatters';
  25. import {getCountSeries, getCrashFreeRate, getSeriesSum} from 'sentry/utils/sessions';
  26. import {Color} from 'sentry/utils/theme';
  27. import {displayCrashFreePercent} from 'sentry/views/releases/utils';
  28. import {ProjectBadge, ProjectBadgeContainer} from './styles';
  29. import {groupByTrend} from './utils';
  30. type Props = AsyncComponent['props'] & {
  31. organization: Organization;
  32. projects: Project[];
  33. period?: string | null;
  34. } & DateTimeObject;
  35. type State = AsyncComponent['state'] & {
  36. /** weekly selected date range */
  37. periodSessions: SessionApiResponse | null;
  38. /** Locked to last 7 days */
  39. weekSessions: SessionApiResponse | null;
  40. };
  41. class TeamStability extends AsyncComponent<Props, State> {
  42. shouldRenderBadRequests = true;
  43. getDefaultState(): State {
  44. return {
  45. ...super.getDefaultState(),
  46. weekSessions: null,
  47. periodSessions: null,
  48. };
  49. }
  50. getEndpoints() {
  51. const {organization, start, end, period, utc, projects} = this.props;
  52. const projectsWithSessions = projects.filter(project => project.hasSessions);
  53. if (projectsWithSessions.length === 0) {
  54. return [];
  55. }
  56. const datetime = {start, end, period, utc};
  57. const commonQuery = {
  58. environment: [],
  59. project: projectsWithSessions.map(p => p.id),
  60. field: 'sum(session)',
  61. groupBy: ['session.status', 'project'],
  62. interval: '1d',
  63. };
  64. const endpoints: ReturnType<AsyncComponent['getEndpoints']> = [
  65. [
  66. 'periodSessions',
  67. `/organizations/${organization.slug}/sessions/`,
  68. {
  69. query: {
  70. ...commonQuery,
  71. ...normalizeDateTimeParams(datetime),
  72. },
  73. },
  74. ],
  75. [
  76. 'weekSessions',
  77. `/organizations/${organization.slug}/sessions/`,
  78. {
  79. query: {
  80. ...commonQuery,
  81. statsPeriod: '7d',
  82. },
  83. },
  84. ],
  85. ];
  86. return endpoints;
  87. }
  88. componentDidUpdate(prevProps: Props) {
  89. const {projects, start, end, period, utc} = this.props;
  90. if (
  91. prevProps.start !== start ||
  92. prevProps.end !== end ||
  93. prevProps.period !== period ||
  94. prevProps.utc !== utc ||
  95. !isEqual(prevProps.projects, projects)
  96. ) {
  97. this.remountComponent();
  98. }
  99. }
  100. getScore(projectId: number, dataset: 'week' | 'period'): number | null {
  101. const {periodSessions, weekSessions} = this.state;
  102. const sessions = dataset === 'week' ? weekSessions : periodSessions;
  103. const projectGroups = sessions?.groups.filter(
  104. group => group.by.project === projectId
  105. );
  106. return getCrashFreeRate(projectGroups, SessionFieldWithOperation.SESSIONS);
  107. }
  108. getTrend(projectId: number): number | null {
  109. const periodScore = this.getScore(projectId, 'period');
  110. const weekScore = this.getScore(projectId, 'week');
  111. if (periodScore === null || weekScore === null) {
  112. return null;
  113. }
  114. return weekScore - periodScore;
  115. }
  116. getMiniBarChartSeries(project: Project, response: SessionApiResponse) {
  117. const sumSessions = getSeriesSum(
  118. response.groups.filter(group => group.by.project === Number(project.id)),
  119. SessionFieldWithOperation.SESSIONS,
  120. response.intervals
  121. );
  122. const countSeries = getCountSeries(
  123. SessionFieldWithOperation.SESSIONS,
  124. response.groups.find(
  125. g =>
  126. g.by.project === Number(project.id) &&
  127. g.by['session.status'] === SessionStatus.HEALTHY
  128. ),
  129. response.intervals
  130. );
  131. const countSeriesWeeklyTotals: number[] = Array(sumSessions.length / 7).fill(0);
  132. countSeries.forEach(
  133. (s, idx) => (countSeriesWeeklyTotals[Math.floor(idx / 7)] += s.value)
  134. );
  135. const sumSessionsWeeklyTotals: number[] = Array(sumSessions.length / 7).fill(0);
  136. sumSessions.forEach((s, idx) => (sumSessionsWeeklyTotals[Math.floor(idx / 7)] += s));
  137. const data = countSeriesWeeklyTotals.map((value, idx) => ({
  138. name: countSeries[idx * 7].name,
  139. value: sumSessionsWeeklyTotals[idx]
  140. ? formatFloat((value / sumSessionsWeeklyTotals[idx]) * 100, 2)
  141. : 0,
  142. }));
  143. return [{seriesName: t('Crash Free Sessions'), data}];
  144. }
  145. renderLoading() {
  146. return this.renderBody();
  147. }
  148. renderScore(projectId: string, dataset: 'week' | 'period') {
  149. const {loading} = this.state;
  150. if (loading) {
  151. return (
  152. <div>
  153. <Placeholder width="80px" height="25px" />
  154. </div>
  155. );
  156. }
  157. const score = this.getScore(Number(projectId), dataset);
  158. if (score === null) {
  159. return '\u2014';
  160. }
  161. return displayCrashFreePercent(score);
  162. }
  163. renderTrend(projectId: string) {
  164. const {loading} = this.state;
  165. if (loading) {
  166. return (
  167. <div>
  168. <Placeholder width="80px" height="25px" />
  169. </div>
  170. );
  171. }
  172. const trend = this.getTrend(Number(projectId));
  173. if (trend === null) {
  174. return '\u2014';
  175. }
  176. return (
  177. <SubText color={trend >= 0 ? 'green300' : 'red300'}>
  178. {`${round(Math.abs(trend), 3)}\u0025`}
  179. <PaddedIconArrow direction={trend >= 0 ? 'up' : 'down'} size="xs" />
  180. </SubText>
  181. );
  182. }
  183. renderBody() {
  184. const {organization, projects, period} = this.props;
  185. const sortedProjects = projects
  186. .map(project => ({project, trend: this.getTrend(Number(project.id)) ?? 0}))
  187. .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend));
  188. const groupedProjects = groupByTrend(sortedProjects);
  189. return (
  190. <SessionsRequest
  191. api={this.api}
  192. project={projects.map(({id}) => Number(id))}
  193. organization={organization}
  194. interval="1d"
  195. groupBy={['session.status', 'project']}
  196. field={[SessionFieldWithOperation.SESSIONS]}
  197. statsPeriod={period}
  198. >
  199. {({response, loading}) => (
  200. <StyledPanelTable
  201. isEmpty={projects.length === 0}
  202. emptyMessage={t('No projects with release health enabled')}
  203. emptyAction={
  204. <Button
  205. size="sm"
  206. external
  207. href="https://docs.sentry.io/platforms/dotnet/guides/nlog/configuration/releases/#release-health"
  208. >
  209. {t('Learn More')}
  210. </Button>
  211. }
  212. headers={[
  213. t('Project'),
  214. <RightAligned key="last">{tct('Last [period]', {period})}</RightAligned>,
  215. <RightAligned key="avg">{tct('[period] Avg', {period})}</RightAligned>,
  216. <RightAligned key="curr">{t('Last 7 Days')}</RightAligned>,
  217. <RightAligned key="diff">{t('Difference')}</RightAligned>,
  218. ]}
  219. >
  220. {groupedProjects.map(({project}) => (
  221. <Fragment key={project.id}>
  222. <ProjectBadgeContainer>
  223. <ProjectBadge avatarSize={18} project={project} />
  224. </ProjectBadgeContainer>
  225. <div>
  226. {response && !loading && (
  227. <MiniBarChart
  228. isGroupedByDate
  229. showTimeInTooltip
  230. series={this.getMiniBarChartSeries(project, response)}
  231. height={25}
  232. tooltipFormatter={(value: number) => `${value.toLocaleString()}%`}
  233. />
  234. )}
  235. </div>
  236. <ScoreWrapper>{this.renderScore(project.id, 'period')}</ScoreWrapper>
  237. <ScoreWrapper>{this.renderScore(project.id, 'week')}</ScoreWrapper>
  238. <ScoreWrapper>{this.renderTrend(project.id)}</ScoreWrapper>
  239. </Fragment>
  240. ))}
  241. </StyledPanelTable>
  242. )}
  243. </SessionsRequest>
  244. );
  245. }
  246. }
  247. export default TeamStability;
  248. const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>`
  249. grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr 0.2fr;
  250. font-size: ${p => p.theme.fontSizeMedium};
  251. white-space: nowrap;
  252. margin-bottom: 0;
  253. border: 0;
  254. box-shadow: unset;
  255. /* overflow when bar chart tooltip gets cutoff for the top row */
  256. overflow: visible;
  257. & > div {
  258. padding: ${space(1)} ${space(2)};
  259. }
  260. ${p =>
  261. p.isEmpty &&
  262. css`
  263. & > div:last-child {
  264. padding: 48px ${space(2)};
  265. }
  266. `}
  267. `;
  268. const RightAligned = styled('span')`
  269. text-align: right;
  270. `;
  271. const ScoreWrapper = styled('div')`
  272. display: flex;
  273. align-items: center;
  274. justify-content: flex-end;
  275. text-align: right;
  276. `;
  277. const PaddedIconArrow = styled(IconArrow)`
  278. margin: 0 ${space(0.5)};
  279. `;
  280. const SubText = styled('div')<{color: Color}>`
  281. color: ${p => p.theme[p.color]};
  282. `;