Skip to content

Commit 74a7b21

Browse files
feat(uptime): Add percentile to overview rows (#96793)
Looks like this <img alt="clipboard.png" width="328" src="https://i.imgur.com/p8zy6ce.png" />
1 parent 35a182e commit 74a7b21

File tree

8 files changed

+272
-52
lines changed

8 files changed

+272
-52
lines changed

static/app/views/alerts/rules/uptime/types.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export interface UptimeCheck {
4444
uptimeCheckId: string;
4545
}
4646

47+
export interface UptimeSummary {
48+
downtimeChecks: number;
49+
failedChecks: number;
50+
missedWindowChecks: number;
51+
totalChecks: number;
52+
}
53+
4754
export enum CheckStatusReason {
4855
FAILURE = 'failure',
4956
TIMEOUT = 'timeout',

static/app/views/insights/uptime/components/overviewTimeline/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {space} from 'sentry/styles/space';
1414
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
1515
import {useDimensions} from 'sentry/utils/useDimensions';
1616
import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types';
17+
import {useUptimeMonitorSummaries} from 'sentry/views/insights/uptime/utils/useUptimeMonitorSummary';
1718

1819
import {OverviewRow} from './overviewRow';
1920

@@ -29,6 +30,11 @@ export function OverviewTimeline({uptimeRules}: Props) {
2930
const timeWindowConfig = useTimeWindowConfig({timelineWidth});
3031
const dateNavigation = useDateNavigation();
3132

33+
const {data: summaries} = useUptimeMonitorSummaries({
34+
timeWindowConfig,
35+
ruleIds: uptimeRules.map(rule => rule.id),
36+
});
37+
3238
return (
3339
<MonitorListPanel role="region">
3440
<TimelineWidthTracker ref={elementRef} />
@@ -66,6 +72,7 @@ export function OverviewTimeline({uptimeRules}: Props) {
6672
key={uptimeRule.id}
6773
timeWindowConfig={timeWindowConfig}
6874
uptimeRule={uptimeRule}
75+
summary={summaries?.[uptimeRule.id] ?? null}
6976
/>
7077
))}
7178
</UptimeAlertRow>

static/app/views/insights/uptime/components/overviewTimeline/overviewRow.tsx

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@ import {CheckInPlaceholder} from 'sentry/components/checkInTimeline/checkInPlace
66
import {CheckInTimeline} from 'sentry/components/checkInTimeline/checkInTimeline';
77
import type {TimeWindowConfig} from 'sentry/components/checkInTimeline/types';
88
import {Tag} from 'sentry/components/core/badge/tag';
9+
import {Flex} from 'sentry/components/core/layout';
910
import {Link} from 'sentry/components/core/link';
11+
import {Text} from 'sentry/components/core/text';
1012
import ActorBadge from 'sentry/components/idBadge/actorBadge';
1113
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
12-
import {IconTimer, IconUser} from 'sentry/icons';
14+
import Placeholder from 'sentry/components/placeholder';
15+
import {IconStats, IconTimer, IconUser} from 'sentry/icons';
1316
import {t, tn} from 'sentry/locale';
1417
import {space} from 'sentry/styles/space';
1518
import getDuration from 'sentry/utils/duration/getDuration';
1619
import {useLocation} from 'sentry/utils/useLocation';
1720
import useOrganization from 'sentry/utils/useOrganization';
1821
import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
1922
import {makeAlertsPathname} from 'sentry/views/alerts/pathnames';
20-
import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types';
23+
import type {UptimeRule, UptimeSummary} from 'sentry/views/alerts/rules/uptime/types';
24+
import {UptimePercentile} from 'sentry/views/insights/uptime/components/percentile';
2125
import {
2226
checkStatusPrecedent,
2327
statusToText,
@@ -34,9 +38,15 @@ interface Props {
3438
* rule name.
3539
*/
3640
singleRuleView?: boolean;
41+
summary?: UptimeSummary | null;
3742
}
3843

39-
export function OverviewRow({uptimeRule, timeWindowConfig, singleRuleView}: Props) {
44+
export function OverviewRow({
45+
summary,
46+
uptimeRule,
47+
timeWindowConfig,
48+
singleRuleView,
49+
}: Props) {
4050
const organization = useOrganization();
4151
const project = useProjectFromSlug({
4252
organization,
@@ -65,26 +75,40 @@ export function OverviewRow({uptimeRule, timeWindowConfig, singleRuleView}: Prop
6575
<DetailsHeadline>
6676
<Name>{uptimeRule.name}</Name>
6777
</DetailsHeadline>
68-
<DetailsContainer>
78+
<Flex direction="column" gap="xs">
6979
<OwnershipDetails>
7080
{project && <ProjectBadge project={project} avatarSize={12} disableLink />}
7181
{uptimeRule.owner ? (
7282
<ActorBadge actor={uptimeRule.owner} avatarSize={12} />
7383
) : (
74-
<UnassignedLabel>
84+
<Flex gap="xs" align="center">
7585
<IconUser size="xs" />
7686
{t('Unassigned')}
77-
</UnassignedLabel>
87+
</Flex>
7888
)}
7989
</OwnershipDetails>
80-
<ScheduleDetails>
81-
<IconTimer size="xs" />
82-
{t('Checked every %s', getDuration(uptimeRule.intervalSeconds))}
83-
</ScheduleDetails>
90+
<Flex gap="xs" align="center">
91+
<Flex gap="xs" align="center">
92+
<IconTimer color="subText" size="xs" />
93+
<Text size="xs" variant="muted">
94+
{t('Checked every %s', getDuration(uptimeRule.intervalSeconds))}
95+
</Text>
96+
</Flex>
97+
{summary === undefined ? null : summary === null ? (
98+
<Text size="xs">
99+
<Placeholder width="60px" height="1lh" />
100+
</Text>
101+
) : (
102+
<Flex gap="xs" align="center">
103+
<IconStats color="subText" size="xs" />
104+
<UptimePercentile size="xs" summary={summary} />
105+
</Flex>
106+
)}
107+
</Flex>
84108
<MonitorStatuses>
85109
{uptimeRule.status === 'disabled' && <Tag>{t('Disabled')}</Tag>}
86110
</MonitorStatuses>
87-
</DetailsContainer>
111+
</Flex>
88112
</DetailsLink>
89113
</DetailsArea>
90114
);
@@ -136,12 +160,6 @@ const DetailsHeadline = styled('div')`
136160
grid-template-columns: 1fr minmax(30px, max-content);
137161
`;
138162

139-
const DetailsContainer = styled('div')`
140-
display: flex;
141-
flex-direction: column;
142-
gap: ${space(0.5)};
143-
`;
144-
145163
const OwnershipDetails = styled('div')`
146164
display: flex;
147165
flex-wrap: wrap;
@@ -151,26 +169,12 @@ const OwnershipDetails = styled('div')`
151169
font-size: ${p => p.theme.fontSize.sm};
152170
`;
153171

154-
const UnassignedLabel = styled('div')`
155-
display: flex;
156-
gap: ${space(0.5)};
157-
align-items: center;
158-
`;
159-
160172
const Name = styled('h3')`
161173
font-size: ${p => p.theme.fontSize.lg};
162174
word-break: break-word;
163175
margin-bottom: ${space(0.5)};
164176
`;
165177

166-
const ScheduleDetails = styled('small')`
167-
display: flex;
168-
gap: ${space(0.5)};
169-
align-items: center;
170-
color: ${p => p.theme.subText};
171-
font-size: ${p => p.theme.fontSize.sm};
172-
`;
173-
174178
const MonitorStatuses = styled('div')`
175179
display: flex;
176180
gap: ${space(0.5)};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {UptimeSummaryFixture} from 'sentry-fixture/uptimeSummary';
2+
3+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
4+
5+
import {UptimePercentile} from './percentile';
6+
7+
describe('UptimePercentile', () => {
8+
const mockSummary = UptimeSummaryFixture();
9+
10+
it('calculates and displays uptime percentage correctly', () => {
11+
// Known checks = totalChecks - missedWindowChecks = 100 - 2 = 98
12+
// Uptime calculation = (knownChecks - downtimeChecks) / knownChecks = (98 - 5) / 98 = 93/98 = 0.9489...
13+
// Uptime = 94.897% which rounds down to 94.897%
14+
render(<UptimePercentile summary={mockSummary} />);
15+
16+
expect(screen.getByText('94.897%')).toBeInTheDocument();
17+
});
18+
19+
it('displays 100% uptime when all checks are successful', () => {
20+
const perfectSummary = UptimeSummaryFixture({
21+
downtimeChecks: 0,
22+
failedChecks: 0,
23+
missedWindowChecks: 0,
24+
});
25+
26+
render(<UptimePercentile summary={perfectSummary} />);
27+
28+
expect(screen.getByText('100%')).toBeInTheDocument();
29+
});
30+
31+
it('displays 0% uptime when no checks are known', () => {
32+
const noKnownChecksSummary = UptimeSummaryFixture({
33+
downtimeChecks: 0,
34+
failedChecks: 0,
35+
missedWindowChecks: 100,
36+
});
37+
38+
render(<UptimePercentile summary={noKnownChecksSummary} />);
39+
40+
expect(screen.getByText('0.0%')).toBeInTheDocument();
41+
});
42+
43+
it('displays uptime when some checks are down', () => {
44+
const allFailedSummary = UptimeSummaryFixture({
45+
downtimeChecks: 50,
46+
failedChecks: 48,
47+
missedWindowChecks: 2,
48+
});
49+
// knownChecks = 100 - 2 = 98, uptime = (98 - 50) / 98 = 48/98 = 0.48979... = 48.979%
50+
51+
render(<UptimePercentile summary={allFailedSummary} />);
52+
53+
expect(screen.getByText('48.979%')).toBeInTheDocument();
54+
});
55+
56+
it('handles zero total checks', () => {
57+
const zeroChecksSummary = UptimeSummaryFixture({
58+
totalChecks: 0,
59+
downtimeChecks: 0,
60+
failedChecks: 0,
61+
missedWindowChecks: 0,
62+
});
63+
64+
render(<UptimePercentile summary={zeroChecksSummary} />);
65+
66+
expect(screen.getByText('0.0%')).toBeInTheDocument();
67+
});
68+
69+
it('rounds down to 3 decimal places', () => {
70+
const preciseSummary = UptimeSummaryFixture({
71+
downtimeChecks: 1,
72+
failedChecks: 0,
73+
missedWindowChecks: 0,
74+
totalChecks: 7,
75+
});
76+
// Uptime = 6/7 = 0.857142... which should round down to 85.714%
77+
78+
render(<UptimePercentile summary={preciseSummary} />);
79+
80+
expect(screen.getByText('85.714%')).toBeInTheDocument();
81+
});
82+
83+
it('shows tooltip with detailed breakdown on hover', async () => {
84+
render(<UptimePercentile summary={mockSummary} />);
85+
86+
const percentageText = screen.getByText('94.897%');
87+
await userEvent.hover(percentageText);
88+
89+
expect(await screen.findByText(/uptime/i)).toBeInTheDocument();
90+
expect(screen.getByText('Up Checks')).toBeInTheDocument();
91+
expect(screen.getByText('Failed Checks')).toBeInTheDocument();
92+
expect(screen.getByText('Down Checks')).toBeInTheDocument();
93+
});
94+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {Flex, Grid} from 'sentry/components/core/layout';
2+
import {Text} from 'sentry/components/core/text';
3+
import type {BaseTextProps} from 'sentry/components/core/text/text';
4+
import {Tooltip} from 'sentry/components/core/tooltip';
5+
import {t} from 'sentry/locale';
6+
import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
7+
import {CheckIndicator} from 'sentry/views/alerts/rules/uptime/checkIndicator';
8+
import {CheckStatus, type UptimeSummary} from 'sentry/views/alerts/rules/uptime/types';
9+
10+
type UptimePercentileProps = {
11+
summary: UptimeSummary;
12+
size?: BaseTextProps['size'];
13+
};
14+
15+
export function UptimePercentile({summary, size}: UptimePercentileProps) {
16+
const knownChecks = summary.totalChecks - summary.missedWindowChecks;
17+
18+
if (knownChecks === 0) {
19+
return <Text variant="muted">0.0%</Text>;
20+
}
21+
22+
const percentFull = ((knownChecks - summary.downtimeChecks) / knownChecks) * 100;
23+
const successChecks = knownChecks - summary.downtimeChecks - summary.failedChecks;
24+
25+
// Round down to 3 decimals
26+
const percent = Math.floor(percentFull * 1000) / 1000;
27+
28+
const tooltip = (
29+
<Flex direction="column" gap="md" style={{textAlign: 'left'}}>
30+
{t('The percent uptime of this monitor in the selected time period.')}
31+
<Grid columns={'max-content max-content max-content'} gap="xs md">
32+
<span>
33+
<CheckIndicator status={CheckStatus.SUCCESS} width={8} />
34+
</span>
35+
<span>{t('Up Checks')}</span>
36+
<span>{formatAbbreviatedNumber(successChecks)}</span>
37+
<span>
38+
<CheckIndicator status={CheckStatus.FAILURE} width={8} />
39+
</span>
40+
<span>{t('Failed Checks')}</span>
41+
<span>{formatAbbreviatedNumber(summary.failedChecks)}</span>
42+
<span>
43+
<CheckIndicator status={CheckStatus.FAILURE_INCIDENT} width={8} />
44+
</span>
45+
<span>{t('Down Checks')}</span>
46+
<span>{formatAbbreviatedNumber(summary.downtimeChecks)}</span>
47+
</Grid>
48+
</Flex>
49+
);
50+
51+
return (
52+
<Tooltip skipWrapper title={tooltip}>
53+
<Text
54+
tabular
55+
size={size}
56+
variant={percent > 99 ? 'success' : percent > 95 ? 'warning' : 'danger'}
57+
>
58+
{`${percent}%`}
59+
</Text>
60+
</Tooltip>
61+
);
62+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type {TimeWindowConfig} from 'sentry/components/checkInTimeline/types';
2+
import {useApiQuery} from 'sentry/utils/queryClient';
3+
import useOrganization from 'sentry/utils/useOrganization';
4+
import type {UptimeSummary} from 'sentry/views/alerts/rules/uptime/types';
5+
6+
interface Options {
7+
/**
8+
* The list of uptime monitor IDs to fetch summaries for. These are the numeric
9+
* IDs of the UptimeRule id's
10+
*/
11+
ruleIds: string[];
12+
/**
13+
* The window configuration object
14+
*/
15+
timeWindowConfig: TimeWindowConfig;
16+
}
17+
18+
/**
19+
* Fetches Uptime Monitor summaries
20+
*/
21+
export function useUptimeMonitorSummaries({ruleIds, timeWindowConfig}: Options) {
22+
const {start, end} = timeWindowConfig;
23+
24+
const selectionQuery = {
25+
start: Math.floor(start.getTime() / 1000),
26+
end: Math.floor(end.getTime() / 1000),
27+
};
28+
29+
const organization = useOrganization();
30+
const monitorStatsQueryKey = `/organizations/${organization.slug}/uptime-summary/`;
31+
32+
return useApiQuery<Record<string, UptimeSummary>>(
33+
[
34+
monitorStatsQueryKey,
35+
{
36+
query: {
37+
projectUptimeSubscriptionId: ruleIds,
38+
...selectionQuery,
39+
},
40+
},
41+
],
42+
{staleTime: 0}
43+
);
44+
}

0 commit comments

Comments
 (0)