import {Fragment} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import isEqual from 'lodash/isEqual'; import round from 'lodash/round'; import AsyncComponent from 'sentry/components/asyncComponent'; import Button from 'sentry/components/button'; import MiniBarChart from 'sentry/components/charts/miniBarChart'; import SessionsRequest from 'sentry/components/charts/sessionsRequest'; import {DateTimeObject} from 'sentry/components/charts/utils'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import PanelTable from 'sentry/components/panels/panelTable'; import Placeholder from 'sentry/components/placeholder'; import {IconArrow} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import space from 'sentry/styles/space'; import { Organization, Project, SessionApiResponse, SessionFieldWithOperation, SessionStatus, } from 'sentry/types'; import {formatFloat} from 'sentry/utils/formatters'; import {getCountSeries, getCrashFreeRate, getSeriesSum} from 'sentry/utils/sessions'; import {Color} from 'sentry/utils/theme'; import {displayCrashFreePercent} from 'sentry/views/releases/utils'; import {ProjectBadge, ProjectBadgeContainer} from './styles'; import {groupByTrend} from './utils'; type Props = AsyncComponent['props'] & { organization: Organization; projects: Project[]; period?: string | null; } & DateTimeObject; type State = AsyncComponent['state'] & { /** weekly selected date range */ periodSessions: SessionApiResponse | null; /** Locked to last 7 days */ weekSessions: SessionApiResponse | null; }; class TeamStability extends AsyncComponent { shouldRenderBadRequests = true; getDefaultState(): State { return { ...super.getDefaultState(), weekSessions: null, periodSessions: null, }; } getEndpoints() { const {organization, start, end, period, utc, projects} = this.props; const projectsWithSessions = projects.filter(project => project.hasSessions); if (projectsWithSessions.length === 0) { return []; } const datetime = {start, end, period, utc}; const commonQuery = { environment: [], project: projectsWithSessions.map(p => p.id), field: 'sum(session)', groupBy: ['session.status', 'project'], interval: '1d', }; const endpoints: ReturnType = [ [ 'periodSessions', `/organizations/${organization.slug}/sessions/`, { query: { ...commonQuery, ...normalizeDateTimeParams(datetime), }, }, ], [ 'weekSessions', `/organizations/${organization.slug}/sessions/`, { query: { ...commonQuery, statsPeriod: '7d', }, }, ], ]; return endpoints; } componentDidUpdate(prevProps: Props) { const {projects, start, end, period, utc} = this.props; if ( prevProps.start !== start || prevProps.end !== end || prevProps.period !== period || prevProps.utc !== utc || !isEqual(prevProps.projects, projects) ) { this.remountComponent(); } } getScore(projectId: number, dataset: 'week' | 'period'): number | null { const {periodSessions, weekSessions} = this.state; const sessions = dataset === 'week' ? weekSessions : periodSessions; const projectGroups = sessions?.groups.filter( group => group.by.project === projectId ); return getCrashFreeRate(projectGroups, SessionFieldWithOperation.SESSIONS); } getTrend(projectId: number): number | null { const periodScore = this.getScore(projectId, 'period'); const weekScore = this.getScore(projectId, 'week'); if (periodScore === null || weekScore === null) { return null; } return weekScore - periodScore; } getMiniBarChartSeries(project: Project, response: SessionApiResponse) { const sumSessions = getSeriesSum( response.groups.filter(group => group.by.project === Number(project.id)), SessionFieldWithOperation.SESSIONS, response.intervals ); const countSeries = getCountSeries( SessionFieldWithOperation.SESSIONS, response.groups.find( g => g.by.project === Number(project.id) && g.by['session.status'] === SessionStatus.HEALTHY ), response.intervals ); const countSeriesWeeklyTotals: number[] = Array(sumSessions.length / 7).fill(0); countSeries.forEach( (s, idx) => (countSeriesWeeklyTotals[Math.floor(idx / 7)] += s.value) ); const sumSessionsWeeklyTotals: number[] = Array(sumSessions.length / 7).fill(0); sumSessions.forEach((s, idx) => (sumSessionsWeeklyTotals[Math.floor(idx / 7)] += s)); const data = countSeriesWeeklyTotals.map((value, idx) => ({ name: countSeries[idx * 7].name, value: sumSessionsWeeklyTotals[idx] ? formatFloat((value / sumSessionsWeeklyTotals[idx]) * 100, 2) : 0, })); return [{seriesName: t('Crash Free Sessions'), data}]; } renderLoading() { return this.renderBody(); } renderScore(projectId: string, dataset: 'week' | 'period') { const {loading} = this.state; if (loading) { return (
); } const score = this.getScore(Number(projectId), dataset); if (score === null) { return '\u2014'; } return displayCrashFreePercent(score); } renderTrend(projectId: string) { const {loading} = this.state; if (loading) { return (
); } const trend = this.getTrend(Number(projectId)); if (trend === null) { return '\u2014'; } return ( = 0 ? 'green300' : 'red300'}> {`${round(Math.abs(trend), 3)}\u0025`} = 0 ? 'up' : 'down'} size="xs" /> ); } renderBody() { const {organization, projects, period} = this.props; const sortedProjects = projects .map(project => ({project, trend: this.getTrend(Number(project.id)) ?? 0})) .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend)); const groupedProjects = groupByTrend(sortedProjects); return ( Number(id))} organization={organization} interval="1d" groupBy={['session.status', 'project']} field={[SessionFieldWithOperation.SESSIONS]} statsPeriod={period} > {({response, loading}) => ( {t('Learn More')} } headers={[ t('Project'), {tct('Last [period]', {period})}, {tct('[period] Avg', {period})}, {t('Last 7 Days')}, {t('Difference')}, ]} > {groupedProjects.map(({project}) => (
{response && !loading && ( `${value.toLocaleString()}%`} /> )}
{this.renderScore(project.id, 'period')} {this.renderScore(project.id, 'week')} {this.renderTrend(project.id)}
))}
)}
); } } export default TeamStability; const StyledPanelTable = styled(PanelTable)<{isEmpty: boolean}>` grid-template-columns: 1fr 0.2fr 0.2fr 0.2fr 0.2fr; font-size: ${p => p.theme.fontSizeMedium}; white-space: nowrap; margin-bottom: 0; border: 0; box-shadow: unset; /* overflow when bar chart tooltip gets cutoff for the top row */ overflow: visible; & > div { padding: ${space(1)} ${space(2)}; } ${p => p.isEmpty && css` & > div:last-child { padding: 48px ${space(2)}; } `} `; const RightAligned = styled('span')` text-align: right; `; const ScoreWrapper = styled('div')` display: flex; align-items: center; justify-content: flex-end; text-align: right; `; const PaddedIconArrow = styled(IconArrow)` margin: 0 ${space(0.5)}; `; const SubText = styled('div')<{color: Color}>` color: ${p => p.theme[p.color]}; `;