import {Fragment} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import round from 'lodash/round'; import {Button} from 'sentry/components/button'; import MiniBarChart from 'sentry/components/charts/miniBarChart'; import type {DateTimeObject} from 'sentry/components/charts/utils'; import LoadingError from 'sentry/components/loadingError'; 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 type {Organization, Project, SessionApiResponse} from 'sentry/types'; import {SessionFieldWithOperation, SessionStatus} from 'sentry/types'; import {formatFloat} from 'sentry/utils/formatters'; import {useApiQuery} from 'sentry/utils/queryClient'; import {getCountSeries, getCrashFreeRate, getSeriesSum} from 'sentry/utils/sessions'; import type {ColorOrAlias} from 'sentry/utils/theme'; import {displayCrashFreePercent} from 'sentry/views/releases/utils'; import {ProjectBadge, ProjectBadgeContainer} from './styles'; import {groupByTrend} from './utils'; interface TeamStabilityProps extends DateTimeObject { organization: Organization; projects: Project[]; } function TeamStability({ organization, projects, period, start, end, utc, }: TeamStabilityProps) { const projectsWithSessions = projects.filter(project => project.hasSessions); 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 { data: periodSessions, isLoading: isPeriodSessionsLoading, isError: isPeriodSessionsError, refetch: refetchPeriodSessions, } = useApiQuery( [ `/organizations/${organization.slug}/sessions/`, { query: { ...commonQuery, ...normalizeDateTimeParams(datetime), }, }, ], {staleTime: 5000} ); const { data: weekSessions, isLoading: isWeekSessionsLoading, isError: isWeekSessionsError, refetch: refetchWeekSessions, } = useApiQuery( [ `/organizations/${organization.slug}/sessions/`, { query: { ...commonQuery, statsPeriod: '7d', }, }, ], {staleTime: 5000} ); const isLoading = isPeriodSessionsLoading || isWeekSessionsLoading; if (isPeriodSessionsError || isWeekSessionsError) { return ( { refetchPeriodSessions(); refetchWeekSessions(); }} /> ); } function getScore(projectId: number, dataset: 'week' | 'period'): number | null { const sessions = dataset === 'week' ? weekSessions : periodSessions; const projectGroups = sessions?.groups.filter( group => group.by.project === projectId ); return getCrashFreeRate(projectGroups, SessionFieldWithOperation.SESSIONS); } function getTrend(projectId: number): number | null { const periodScore = getScore(projectId, 'period'); const weekScore = getScore(projectId, 'week'); if (periodScore === null || weekScore === null) { return null; } return weekScore - periodScore; } function 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 sumSessionsCount = Math.floor(sumSessions.length / 7); const countSeriesWeeklyTotals: number[] = new Array(sumSessionsCount).fill(0); countSeries.forEach( (s, idx) => (countSeriesWeeklyTotals[Math.floor(idx / 7)] += s.value) ); const sumSessionsWeeklyTotals: number[] = new Array(sumSessionsCount).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}]; } function renderScore(projectId: string, dataset: 'week' | 'period') { if (isLoading) { return (
); } const score = getScore(Number(projectId), dataset); if (score === null) { return '\u2014'; } return displayCrashFreePercent(score); } function renderTrend(projectId: string) { if (isLoading) { return (
); } const trend = getTrend(Number(projectId)); if (trend === null) { return '\u2014'; } return ( = 0 ? 'successText' : 'errorText'}> {`${round(Math.abs(trend), 3)}\u0025`} = 0 ? 'up' : 'down'} size="xs" /> ); } const sortedProjects = projects .map(project => ({project, trend: getTrend(Number(project.id)) ?? 0})) .sort((a, b) => Math.abs(b.trend) - Math.abs(a.trend)); const groupedProjects = groupByTrend(sortedProjects); return ( {t('Learn More')} } headers={[ t('Project'), {tct('Last [period]', {period})}, {tct('[period] Avg', {period})}, {t('Last 7 Days')}, {t('Difference')}, ]} > {groupedProjects.map(({project}) => (
{periodSessions && weekSessions && !isLoading && ( `${value.toLocaleString()}%`} /> )}
{renderScore(project.id, 'period')} {renderScore(project.id, 'week')} {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: ColorOrAlias}>` color: ${p => p.theme[p.color]}; `;