Skip to content

Commit 6bfcabd

Browse files
authored
feat(toolbar): Add a panel to show active alerts (#74657)
New panel to reveal active alerts in your sentry instance: <img width="383" alt="SCR-20240722-kpuw" src="https://github.com/user-attachments/assets/02a88f29-5882-4aac-8323-9d9d6301400e">
1 parent d6e2e28 commit 6bfcabd

File tree

16 files changed

+313
-28
lines changed

16 files changed

+313
-28
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {css} from '@emotion/react';
2+
3+
import ActorAvatar from 'sentry/components/avatar/actorAvatar';
4+
import AlertBadge from 'sentry/components/badge/alertBadge';
5+
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
6+
import Placeholder from 'sentry/components/placeholder';
7+
import TextOverflow from 'sentry/components/textOverflow';
8+
import TimeSince from 'sentry/components/timeSince';
9+
import type {Actor} from 'sentry/types/core';
10+
import type {Organization} from 'sentry/types/organization';
11+
import AlertRuleStatus from 'sentry/views/alerts/list/rules/alertRuleStatus';
12+
import type {Incident, MetricAlert} from 'sentry/views/alerts/types';
13+
import {CombinedAlertType} from 'sentry/views/alerts/types';
14+
import {alertDetailsLink} from 'sentry/views/alerts/utils';
15+
16+
import useConfiguration from '../../hooks/useConfiguration';
17+
import {
18+
badgeWithLabelCss,
19+
gridFlexEndCss,
20+
listItemGridCss,
21+
listItemPlaceholderWrapperCss,
22+
} from '../../styles/listItem';
23+
import {panelInsetContentCss, panelSectionCss} from '../../styles/panel';
24+
import {resetFlexColumnCss, resetFlexRowCss} from '../../styles/reset';
25+
import {smallCss, xSmallCss} from '../../styles/typography';
26+
import InfiniteListItems from '../infiniteListItems';
27+
import InfiniteListState from '../infiniteListState';
28+
import PanelLayout from '../panelLayout';
29+
import SentryAppLink from '../sentryAppLink';
30+
import useTeams from '../teams/useTeams';
31+
32+
import useInfiniteAlertsList from './useInfiniteAlertsList';
33+
34+
export default function AlertsPanel() {
35+
const {projectId, projectSlug, trackAnalytics} = useConfiguration();
36+
const queryResult = useInfiniteAlertsList();
37+
38+
const estimateSize = 84;
39+
const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value
40+
41+
return (
42+
<PanelLayout title="Alerts">
43+
<div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
44+
<span css={[resetFlexRowCss, {gap: 'var(--space50)'}]}>
45+
Active Alerts in{' '}
46+
<SentryAppLink
47+
to={{url: `/projects/${projectSlug}/`}}
48+
onClick={() => {
49+
trackAnalytics?.({
50+
eventKey: `devtoolbar.alerts-list.header.click`,
51+
eventName: `devtoolbar: Click alert-list header`,
52+
});
53+
}}
54+
>
55+
<div css={[resetFlexRowCss, {display: 'inline-flex', gap: 'var(--space50)'}]}>
56+
<ProjectBadge
57+
css={css({'&& img': {boxShadow: 'none'}})}
58+
project={{slug: projectSlug, id: projectId}}
59+
avatarSize={16}
60+
hideName
61+
avatarProps={{hasTooltip: false}}
62+
/>
63+
{projectSlug}
64+
</div>
65+
</SentryAppLink>
66+
</span>
67+
</div>
68+
69+
<div css={resetFlexColumnCss}>
70+
<InfiniteListState
71+
queryResult={queryResult}
72+
backgroundUpdatingMessage={() => null}
73+
loadingMessage={() => (
74+
<div
75+
css={[
76+
resetFlexColumnCss,
77+
panelSectionCss,
78+
panelInsetContentCss,
79+
listItemPlaceholderWrapperCss,
80+
]}
81+
>
82+
<Placeholder height={placeholderHeight} />
83+
<Placeholder height={placeholderHeight} />
84+
<Placeholder height={placeholderHeight} />
85+
<Placeholder height={placeholderHeight} />
86+
</div>
87+
)}
88+
>
89+
<InfiniteListItems
90+
estimateSize={() => estimateSize}
91+
queryResult={queryResult}
92+
itemRenderer={props => <AlertListItem {...props} />}
93+
emptyMessage={() => <p css={panelInsetContentCss}>No items to show</p>}
94+
/>
95+
</InfiniteListState>
96+
</div>
97+
</PanelLayout>
98+
);
99+
}
100+
101+
function AlertListItem({item}: {item: Incident}) {
102+
const {organizationSlug, trackAnalytics} = useConfiguration();
103+
104+
const ownerId = item.alertRule.owner?.split(':').at(1);
105+
106+
const {data: teams} = useTeams(
107+
{idOrSlug: String(ownerId)},
108+
{enabled: Boolean(ownerId)}
109+
);
110+
const ownerTeam = teams?.json.at(0);
111+
112+
const teamActor = ownerId
113+
? {type: 'team' as Actor['type'], id: ownerId, name: ownerTeam?.name ?? ''}
114+
: null;
115+
116+
const rule: MetricAlert = {
117+
type: CombinedAlertType.METRIC,
118+
...item.alertRule,
119+
latestIncident: item,
120+
};
121+
122+
return (
123+
<div
124+
css={[
125+
listItemGridCss,
126+
css`
127+
grid-template-areas:
128+
'badge name time'
129+
'badge message message'
130+
'. icons icons';
131+
grid-template-columns: max-content 1fr max-content;
132+
gap: var(--space25) var(--space100);
133+
`,
134+
]}
135+
>
136+
<div style={{gridArea: 'badge'}}>
137+
<AlertBadge status={item.status} isIssue={false} />
138+
</div>
139+
140+
<div
141+
css={[gridFlexEndCss, xSmallCss]}
142+
style={{gridArea: 'time', color: 'var(--gray300)'}}
143+
>
144+
<TimeSince date={item.dateStarted} unitStyle="extraShort" />
145+
</div>
146+
147+
<TextOverflow css={smallCss} style={{gridArea: 'name'}}>
148+
<SentryAppLink
149+
to={{
150+
url: alertDetailsLink({slug: organizationSlug} as Organization, item),
151+
query: {alert: item.identifier},
152+
}}
153+
onClick={() => {
154+
trackAnalytics?.({
155+
eventKey: `devtoolbar.alert-list.item.click`,
156+
eventName: `devtoolbar: Click alert-list item`,
157+
});
158+
}}
159+
>
160+
<strong>{item.title}</strong>
161+
</SentryAppLink>
162+
</TextOverflow>
163+
164+
<div css={smallCss} style={{gridArea: 'message'}}>
165+
<AlertRuleStatus rule={rule} />
166+
</div>
167+
168+
{teamActor ? (
169+
<div
170+
css={[
171+
badgeWithLabelCss,
172+
xSmallCss,
173+
css`
174+
justify-self: flex-end;
175+
`,
176+
]}
177+
style={{gridArea: 'icons'}}
178+
>
179+
<ActorAvatar actor={teamActor} size={16} hasTooltip={false} />{' '}
180+
<TextOverflow>{teamActor.name}</TextOverflow>
181+
</div>
182+
) : null}
183+
</div>
184+
);
185+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {useMemo} from 'react';
2+
3+
import type {Incident} from 'sentry/views/alerts/types';
4+
5+
import useConfiguration from '../../hooks/useConfiguration';
6+
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
7+
import type {ApiEndpointQueryKey} from '../../types';
8+
9+
export default function useInfiniteFeedbackList() {
10+
const {organizationSlug, projectId} = useConfiguration();
11+
12+
return useFetchInfiniteApiData<Incident[]>({
13+
queryKey: useMemo(
14+
(): ApiEndpointQueryKey => [
15+
'io.sentry.toolbar',
16+
`/organizations/${organizationSlug}/incidents/`,
17+
{
18+
query: {
19+
limit: 25,
20+
queryReferrer: 'devtoolbar',
21+
project: [projectId],
22+
statsPeriod: '14d',
23+
status: 'open',
24+
},
25+
},
26+
],
27+
[organizationSlug, projectId]
28+
),
29+
});
30+
}

static/app/components/devtoolbar/components/feedback/useInfiniteFeedbackList.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {useMemo} from 'react';
22

33
import useConfiguration from '../../hooks/useConfiguration';
44
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
5-
import type {FeedbackIssueListItem} from '../../types';
5+
import type {ApiEndpointQueryKey, FeedbackIssueListItem} from '../../types';
66

77
interface Props {
88
query: string;
@@ -14,7 +14,8 @@ export default function useInfiniteFeedbackList({query}: Props) {
1414

1515
return useFetchInfiniteApiData<FeedbackIssueListItem[]>({
1616
queryKey: useMemo(
17-
() => [
17+
(): ApiEndpointQueryKey => [
18+
'io.sentry.toolbar',
1819
`/organizations/${organizationSlug}/issues/`,
1920
{
2021
query: {

static/app/components/devtoolbar/components/issues/issuesPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export default function FeedbackPanel() {
3232
query: `url:*${transactionName}`,
3333
});
3434

35-
const estimateSize = 108;
35+
const estimateSize = 89;
3636
const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value
3737

3838
return (

static/app/components/devtoolbar/components/issues/useInfiniteIssuesList.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {IssueCategory} from 'sentry/types/group';
55

66
import useConfiguration from '../../hooks/useConfiguration';
77
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
8+
import type {ApiEndpointQueryKey} from '../../types';
89

910
interface Props {
1011
query: string;
@@ -16,7 +17,8 @@ export default function useInfiniteIssuesList({query}: Props) {
1617

1718
return useFetchInfiniteApiData<Group[]>({
1819
queryKey: useMemo(
19-
() => [
20+
(): ApiEndpointQueryKey => [
21+
'io.sentry.toolbar',
2022
`/organizations/${organizationSlug}/issues/`,
2123
{
2224
query: {

static/app/components/devtoolbar/components/navigation.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {css} from '@emotion/react';
22

33
import useConfiguration from 'sentry/components/devtoolbar/hooks/useConfiguration';
44
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
5-
import {IconClose, IconIssues, IconMegaphone} from 'sentry/icons';
5+
import {IconClose, IconIssues, IconMegaphone, IconSiren} from 'sentry/icons';
66

77
import usePlacementCss from '../hooks/usePlacementCss';
88
import useToolbarRoute from '../hooks/useToolbarRoute';
@@ -22,8 +22,9 @@ export default function Navigation({setIsHidden}: {setIsHidden: (val: boolean) =
2222
placement.navigation.css,
2323
]}
2424
>
25-
<NavButton panelName="issues" label={'Issues'} icon={<IconIssues />} />
26-
<NavButton panelName="feedback" label={'User Feedback'} icon={<IconMegaphone />} />
25+
<NavButton panelName="issues" label="Issues" icon={<IconIssues />} />
26+
<NavButton panelName="feedback" label="User Feedback" icon={<IconMegaphone />} />
27+
<NavButton panelName="alerts" label="Active Alerts" icon={<IconSiren />} />
2728
<HideButton
2829
onClick={() => {
2930
setIsHidden(true);

static/app/components/devtoolbar/components/panelRouter.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import {lazy} from 'react';
22

33
import useToolbarRoute from '../hooks/useToolbarRoute';
44

5+
const PanelAlerts = lazy(() => import('./alerts/alertsPanel'));
56
const PanelFeedback = lazy(() => import('./feedback/feedbackPanel'));
67
const PanelIssues = lazy(() => import('./issues/issuesPanel'));
78

89
export default function PanelRouter() {
910
const {state} = useToolbarRoute();
1011

1112
switch (state.activePanel) {
13+
case 'alerts':
14+
return <PanelAlerts />;
1215
case 'feedback':
1316
return <PanelFeedback />;
1417
case 'issues':

static/app/components/devtoolbar/components/providers.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface Props {
1717

1818
export default function Providers({children, config, container}: Props) {
1919
const queryClient = useMemo(() => new QueryClient({}), []);
20+
2021
const myCache = useMemo(
2122
() =>
2223
createCache({

static/app/components/devtoolbar/components/sentryAppLink.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ export default function SentryAppLink({children, to, onClick}: Props) {
2121
return (
2222
<a
2323
css={inlineLinkCss}
24-
onClick={onClick}
2524
href={url}
26-
target="_blank"
25+
onClick={onClick}
2726
rel="noreferrer noopener"
27+
target="_blank"
2828
>
2929
{children}
3030
</a>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {useMemo} from 'react';
2+
3+
import type {Team} from 'sentry/types/organization';
4+
5+
import useConfiguration from '../../hooks/useConfiguration';
6+
import useFetchApiData from '../../hooks/useFetchApiData';
7+
import type {ApiEndpointQueryKey} from '../../types';
8+
9+
interface Props {
10+
idOrSlug?: string;
11+
}
12+
13+
export default function useTeams({idOrSlug}: Props, opts?: {enabled: boolean}) {
14+
const {organizationSlug} = useConfiguration();
15+
16+
return useFetchApiData<Team[]>({
17+
queryKey: useMemo(
18+
(): ApiEndpointQueryKey => [
19+
'io.sentry.toolbar',
20+
`/organizations/${organizationSlug}/teams/`,
21+
{
22+
query: {
23+
query: `id:${idOrSlug}`,
24+
},
25+
},
26+
],
27+
[idOrSlug, organizationSlug]
28+
),
29+
cacheTime: Infinity,
30+
enabled: opts?.enabled ?? true,
31+
});
32+
}

0 commit comments

Comments
 (0)