Skip to content

Commit 9e4b537

Browse files
authored
feat(workflow): View alert rule status in list (#25212)
1 parent 9d0f6d0 commit 9e4b537

File tree

14 files changed

+512
-177
lines changed

14 files changed

+512
-177
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
3+
import AlertBadge from 'app/views/alerts/alertBadge';
4+
import {IncidentStatus} from 'app/views/alerts/types';
5+
6+
export default {
7+
title: 'Features/Alerts/AlertBadge',
8+
component: AlertBadge,
9+
args: {
10+
status: 0,
11+
hideText: false,
12+
isIssue: false,
13+
},
14+
argTypes: {
15+
status: {
16+
control: {
17+
type: 'radio',
18+
options: Object.values(IncidentStatus).filter(Number.isInteger),
19+
labels: IncidentStatus,
20+
},
21+
},
22+
},
23+
};
24+
25+
export const Default = ({...args}) => <AlertBadge {...args} />;

src/sentry/api/serializers/models/alert_rule.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AlertRuleActivityType,
99
AlertRuleExcludedProjects,
1010
AlertRuleTrigger,
11+
Incident,
1112
)
1213
from sentry.models import ACTOR_TYPES, Rule, actor_type_to_class, actor_type_to_string
1314
from sentry.snuba.models import SnubaQueryEventType
@@ -144,15 +145,27 @@ def serialize(self, obj, attrs, user):
144145

145146

146147
class CombinedRuleSerializer(Serializer):
148+
def __init__(self, expand=None):
149+
self.expand = expand or []
150+
147151
def get_attrs(self, item_list, user, **kwargs):
148152
results = super().get_attrs(item_list, user)
149153

150-
alert_rules = serialize([x for x in item_list if isinstance(x, AlertRule)], user=user)
154+
alert_rules = [x for x in item_list if isinstance(x, AlertRule)]
155+
incident_map = {}
156+
if "latestIncident" in self.expand:
157+
for incident in Incident.objects.filter(id__in=[x.incident_id for x in alert_rules]):
158+
incident_map[incident.id] = serialize(incident, user=user)
159+
160+
serialized_alert_rules = serialize(alert_rules, user=user)
151161
rules = serialize([x for x in item_list if isinstance(x, Rule)], user=user)
152162

153163
for item in item_list:
154164
if isinstance(item, AlertRule):
155-
results[item] = alert_rules.pop(0)
165+
alert_rule = serialized_alert_rules.pop(0)
166+
if "latestIncident" in self.expand:
167+
alert_rule["latestIncident"] = incident_map.get(item.incident_id)
168+
results[item] = alert_rule
156169
elif isinstance(item, Rule):
157170
results[item] = rules.pop(0)
158171

src/sentry/incidents/endpoints/organization_alert_rule_index.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
CombinedQuerysetPaginator,
1414
OffsetPaginator,
1515
)
16-
from sentry.api.serializers import CombinedRuleSerializer, serialize
16+
from sentry.api.serializers import serialize
17+
from sentry.api.serializers.models.alert_rule import CombinedRuleSerializer
1718
from sentry.auth.superuser import is_active_superuser
1819
from sentry.incidents.endpoints.serializers import AlertRuleSerializer
1920
from sentry.incidents.models import AlertRule, Incident
@@ -102,6 +103,19 @@ def get(self, request, organization):
102103
alert_rules = alert_rules.filter(team_filter_query)
103104
issue_rules = issue_rules.filter(team_filter_query)
104105

106+
expand = request.GET.getlist("expand", [])
107+
if "latestIncident" in expand:
108+
alert_rules = alert_rules.annotate(
109+
incident_id=Coalesce(
110+
Subquery(
111+
Incident.objects.filter(alert_rule=OuterRef("pk"))
112+
.order_by("-date_started")
113+
.values("id")[:1]
114+
),
115+
Value("-1"),
116+
)
117+
)
118+
105119
is_asc = request.GET.get("asc", False) == "1"
106120
sort_key = request.GET.getlist("sort", ["date_added"])
107121
rule_sort_key = [
@@ -142,7 +156,7 @@ def get(self, request, organization):
142156
return self.paginate(
143157
request,
144158
paginator_cls=CombinedQuerysetPaginator,
145-
on_results=lambda x: serialize(x, request.user, CombinedRuleSerializer()),
159+
on_results=lambda x: serialize(x, request.user, CombinedRuleSerializer(expand=expand)),
146160
default_per_page=25,
147161
intermediaries=[alert_rule_intermediary, rule_intermediary],
148162
desc=not is_asc,

src/sentry/static/sentry/app/components/sidebar/index.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,14 @@ class Sidebar extends React.Component<Props, State> {
314314
);
315315

316316
const alerts = hasOrganization && (
317-
<Feature features={['incidents']}>
318-
{({hasFeature}) => {
319-
const alertsPath = hasFeature
320-
? `/organizations/${organization.slug}/alerts/`
321-
: `/organizations/${organization.slug}/alerts/rules/`;
317+
<Feature features={['incidents', 'alert-list']} requireAll={false}>
318+
{({features}) => {
319+
const hasIncidents = features.includes('incidents');
320+
const hasAlertList = features.includes('alert-list');
321+
const alertsPath =
322+
hasIncidents && !hasAlertList
323+
? `/organizations/${organization.slug}/alerts/`
324+
: `/organizations/${organization.slug}/alerts/rules/`;
322325
return (
323326
<SidebarItem
324327
{...sidebarItemProps}

src/sentry/static/sentry/app/components/sidebar/sidebarItem.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ const SidebarItem = ({
9595
(!hasPanel && router && to && location.pathname.startsWith(to)) ||
9696
(labelString === 'Discover' && location.pathname.includes('/discover/')) ||
9797
// TODO: this won't be necessary once we remove settingsHome
98-
(labelString === 'Settings' && location.pathname.startsWith('/settings/'));
98+
(labelString === 'Settings' && location.pathname.startsWith('/settings/')) ||
99+
(labelString === 'Alerts' &&
100+
location.pathname.includes('/alerts/') &&
101+
!location.pathname.startsWith('/settings/'));
99102

100103
const isActive = active || isActiveRouter;
101104
const isTop = orientation === 'top';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {IconCheckmark, IconFire, IconIssues, IconWarning} from 'app/icons';
5+
import {t} from 'app/locale';
6+
import space from 'app/styles/space';
7+
import {Color} from 'app/utils/theme';
8+
9+
import {IncidentStatus} from './types';
10+
11+
type Props = {
12+
status?: IncidentStatus;
13+
hideText?: boolean;
14+
isIssue?: boolean;
15+
};
16+
17+
function AlertBadge({status, hideText = false, isIssue}: Props) {
18+
let statusText = t('Okay');
19+
let Icon = IconCheckmark;
20+
let color: Color = 'green300';
21+
if (isIssue) {
22+
statusText = t('Issue');
23+
Icon = IconIssues;
24+
color = 'gray300';
25+
} else if (status === IncidentStatus.CRITICAL) {
26+
statusText = t('Critical');
27+
Icon = IconFire;
28+
color = 'red300';
29+
} else if (status === IncidentStatus.WARNING) {
30+
statusText = t('Warning');
31+
Icon = IconWarning;
32+
color = 'yellow300';
33+
}
34+
35+
return (
36+
<Wrapper displayFlex={!hideText}>
37+
<AlertIconWrapper color={color} icon={Icon}>
38+
<Icon color="white" />
39+
</AlertIconWrapper>
40+
41+
{!hideText && <IncidentStatusValue color={color}>{statusText}</IncidentStatusValue>}
42+
</Wrapper>
43+
);
44+
}
45+
46+
export default AlertBadge;
47+
48+
const Wrapper = styled('div')<{displayFlex: boolean}>`
49+
display: ${p => (p.displayFlex ? `flex` : `block`)};
50+
align-items: center;
51+
`;
52+
53+
const AlertIconWrapper = styled('div')<{color: Color; icon: React.ReactNode}>`
54+
display: flex;
55+
align-items: center;
56+
justify-content: center;
57+
flex-shrink: 0;
58+
/* icon warning needs to be treated differently to look visually centered */
59+
line-height: ${p => (p.icon === IconWarning ? undefined : 1)};
60+
left: 3px;
61+
min-width: 30px;
62+
63+
&:before {
64+
content: '';
65+
position: absolute;
66+
width: 22px;
67+
height: 22px;
68+
border-radius: ${p => p.theme.borderRadius};
69+
background-color: ${p => p.theme[p.color]};
70+
transform: rotate(45deg);
71+
}
72+
73+
svg {
74+
width: ${p => (p.icon === IconIssues ? '11px' : '13px')};
75+
z-index: 1;
76+
}
77+
`;
78+
79+
const IncidentStatusValue = styled('div')<{color: Color}>`
80+
margin-left: ${space(1)};
81+
color: ${p => p.theme[p.color]};
82+
`;

src/sentry/static/sentry/app/views/alerts/list/header.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ const AlertHeader = ({router, organization, activeTab}: Props) => {
3030
navigateTo(`/settings/${organization.slug}/projects/:projectId/alerts/`, router);
3131
};
3232

33+
const alertRulesLink = (
34+
<li className={activeTab === 'rules' ? 'active' : ''}>
35+
<GlobalSelectionLink to={`/organizations/${organization.slug}/alerts/rules/`}>
36+
{t('Alert Rules')}
37+
</GlobalSelectionLink>
38+
</li>
39+
);
40+
3341
return (
3442
<React.Fragment>
3543
<BorderlessHeader>
@@ -58,18 +66,35 @@ const AlertHeader = ({router, organization, activeTab}: Props) => {
5866
</BorderlessHeader>
5967
<TabLayoutHeader>
6068
<Layout.HeaderNavTabs underlined>
61-
<Feature features={['incidents']} organization={organization}>
62-
<li className={activeTab === 'stream' ? 'active' : ''}>
63-
<GlobalSelectionLink to={`/organizations/${organization.slug}/alerts/`}>
64-
{t('Metric Alerts')}
65-
</GlobalSelectionLink>
66-
</li>
69+
<Feature features={['alert-list']} organization={organization}>
70+
{({hasFeature}) =>
71+
!hasFeature ? (
72+
<React.Fragment>
73+
<Feature features={['incidents']} organization={organization}>
74+
<li className={activeTab === 'stream' ? 'active' : ''}>
75+
<GlobalSelectionLink
76+
to={`/organizations/${organization.slug}/alerts/`}
77+
>
78+
{t('Metric Alerts')}
79+
</GlobalSelectionLink>
80+
</li>
81+
</Feature>
82+
{alertRulesLink}
83+
</React.Fragment>
84+
) : (
85+
<React.Fragment>
86+
{alertRulesLink}
87+
<li className={activeTab === 'stream' ? 'active' : ''}>
88+
<GlobalSelectionLink
89+
to={`/organizations/${organization.slug}/alerts/`}
90+
>
91+
{t('History')}
92+
</GlobalSelectionLink>
93+
</li>
94+
</React.Fragment>
95+
)
96+
}
6797
</Feature>
68-
<li className={activeTab === 'rules' ? 'active' : ''}>
69-
<GlobalSelectionLink to={`/organizations/${organization.slug}/alerts/rules/`}>
70-
{t('Alert Rules')}
71-
</GlobalSelectionLink>
72-
</li>
7398
</Layout.HeaderNavTabs>
7499
</TabLayoutHeader>
75100
</React.Fragment>

src/sentry/static/sentry/app/views/alerts/rules/details/body.tsx

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import overflowEllipsis from 'app/styles/overflowEllipsis';
2525
import space from 'app/styles/space';
2626
import {Actor, Organization, Project} from 'app/types';
2727
import Projects from 'app/utils/projects';
28-
import theme from 'app/utils/theme';
2928
import Timeline from 'app/views/alerts/rules/details/timeline';
3029
import {DATASET_EVENT_TYPE_FILTERS} from 'app/views/settings/incidentRules/constants';
3130
import {
@@ -36,6 +35,7 @@ import {
3635
} from 'app/views/settings/incidentRules/types';
3736
import {extractEventTypeFilterFromRule} from 'app/views/settings/incidentRules/utils/getEventTypeFilter';
3837

38+
import AlertBadge from '../../alertBadge';
3939
import {Incident, IncidentStatus} from '../../types';
4040

4141
import {API_INTERVAL_POINTS_LIMIT, TIME_OPTIONS, TimePeriodType} from './constants';
@@ -209,18 +209,6 @@ export default class DetailsBody extends React.Component<Props> {
209209
// get current status
210210
const activeIncident = incidents?.find(({dateClosed}) => !dateClosed);
211211
const status = activeIncident ? activeIncident.status : IncidentStatus.CLOSED;
212-
let statusText = t('Okay');
213-
let Icon = IconCheckmark;
214-
let color: string = theme.green300;
215-
if (status === IncidentStatus.CRITICAL) {
216-
statusText = t('Critical');
217-
Icon = IconFire;
218-
color = theme.red300;
219-
} else if (status === IncidentStatus.WARNING) {
220-
statusText = t('Warning');
221-
Icon = IconWarning;
222-
color = theme.yellow300;
223-
}
224212

225213
const latestIncident = incidents?.length ? incidents[0] : null;
226214
// The date at which the alert was triggered or resolved
@@ -235,12 +223,7 @@ export default class DetailsBody extends React.Component<Props> {
235223
<div>
236224
<SidebarHeading noMargin>{t('Status')}</SidebarHeading>
237225
<ItemValue>
238-
<AlertBadge color={color} icon={Icon}>
239-
<AlertIconWrapper>
240-
<Icon color="white" />
241-
</AlertIconWrapper>
242-
</AlertBadge>
243-
<IncidentStatusValue color={color}>{statusText}</IncidentStatusValue>
226+
<AlertBadge status={status} />
244227
</ItemValue>
245228
</div>
246229
<div>
@@ -442,41 +425,6 @@ const ItemValue = styled('div')`
442425
font-size: ${p => p.theme.fontSizeExtraLarge};
443426
`;
444427

445-
const IncidentStatusValue = styled('div')<{color: string}>`
446-
margin-left: 30px;
447-
color: ${p => p.color};
448-
`;
449-
450-
const AlertBadge = styled('div')<{color: string; icon: React.ReactNode}>`
451-
display: flex;
452-
position: absolute;
453-
align-items: center;
454-
justify-content: center;
455-
flex-shrink: 0;
456-
/* icon warning needs to be treated differently to look visually centered */
457-
line-height: ${p => (p.icon === IconWarning ? undefined : 1)};
458-
left: 3px;
459-
460-
&:before {
461-
content: '';
462-
width: 20px;
463-
height: 20px;
464-
border-radius: ${p => p.theme.borderRadius};
465-
background-color: ${p => p.color};
466-
transform: rotate(45deg);
467-
}
468-
`;
469-
470-
const AlertIconWrapper = styled('div')`
471-
position: absolute;
472-
473-
svg {
474-
width: 13px;
475-
position: relative;
476-
top: 1px;
477-
}
478-
`;
479-
480428
const StatusContainer = styled('div')`
481429
display: grid;
482430
grid-template-columns: 50% 50%;

0 commit comments

Comments
 (0)