Skip to content

Commit

Permalink
feat(toolbar): Add a panel to show active alerts (getsentry#74657)
Browse files Browse the repository at this point in the history
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">
  • Loading branch information
ryan953 authored Jul 22, 2024
1 parent d6e2e28 commit 6bfcabd
Show file tree
Hide file tree
Showing 16 changed files with 313 additions and 28 deletions.
185 changes: 185 additions & 0 deletions static/app/components/devtoolbar/components/alerts/alertsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import {css} from '@emotion/react';

import ActorAvatar from 'sentry/components/avatar/actorAvatar';
import AlertBadge from 'sentry/components/badge/alertBadge';
import ProjectBadge from 'sentry/components/idBadge/projectBadge';
import Placeholder from 'sentry/components/placeholder';
import TextOverflow from 'sentry/components/textOverflow';
import TimeSince from 'sentry/components/timeSince';
import type {Actor} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import AlertRuleStatus from 'sentry/views/alerts/list/rules/alertRuleStatus';
import type {Incident, MetricAlert} from 'sentry/views/alerts/types';
import {CombinedAlertType} from 'sentry/views/alerts/types';
import {alertDetailsLink} from 'sentry/views/alerts/utils';

import useConfiguration from '../../hooks/useConfiguration';
import {
badgeWithLabelCss,
gridFlexEndCss,
listItemGridCss,
listItemPlaceholderWrapperCss,
} from '../../styles/listItem';
import {panelInsetContentCss, panelSectionCss} from '../../styles/panel';
import {resetFlexColumnCss, resetFlexRowCss} from '../../styles/reset';
import {smallCss, xSmallCss} from '../../styles/typography';
import InfiniteListItems from '../infiniteListItems';
import InfiniteListState from '../infiniteListState';
import PanelLayout from '../panelLayout';
import SentryAppLink from '../sentryAppLink';
import useTeams from '../teams/useTeams';

import useInfiniteAlertsList from './useInfiniteAlertsList';

export default function AlertsPanel() {
const {projectId, projectSlug, trackAnalytics} = useConfiguration();
const queryResult = useInfiniteAlertsList();

const estimateSize = 84;
const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value

return (
<PanelLayout title="Alerts">
<div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
<span css={[resetFlexRowCss, {gap: 'var(--space50)'}]}>
Active Alerts in{' '}
<SentryAppLink
to={{url: `/projects/${projectSlug}/`}}
onClick={() => {
trackAnalytics?.({
eventKey: `devtoolbar.alerts-list.header.click`,
eventName: `devtoolbar: Click alert-list header`,
});
}}
>
<div css={[resetFlexRowCss, {display: 'inline-flex', gap: 'var(--space50)'}]}>
<ProjectBadge
css={css({'&& img': {boxShadow: 'none'}})}
project={{slug: projectSlug, id: projectId}}
avatarSize={16}
hideName
avatarProps={{hasTooltip: false}}
/>
{projectSlug}
</div>
</SentryAppLink>
</span>
</div>

<div css={resetFlexColumnCss}>
<InfiniteListState
queryResult={queryResult}
backgroundUpdatingMessage={() => null}
loadingMessage={() => (
<div
css={[
resetFlexColumnCss,
panelSectionCss,
panelInsetContentCss,
listItemPlaceholderWrapperCss,
]}
>
<Placeholder height={placeholderHeight} />
<Placeholder height={placeholderHeight} />
<Placeholder height={placeholderHeight} />
<Placeholder height={placeholderHeight} />
</div>
)}
>
<InfiniteListItems
estimateSize={() => estimateSize}
queryResult={queryResult}
itemRenderer={props => <AlertListItem {...props} />}
emptyMessage={() => <p css={panelInsetContentCss}>No items to show</p>}
/>
</InfiniteListState>
</div>
</PanelLayout>
);
}

function AlertListItem({item}: {item: Incident}) {
const {organizationSlug, trackAnalytics} = useConfiguration();

const ownerId = item.alertRule.owner?.split(':').at(1);

const {data: teams} = useTeams(
{idOrSlug: String(ownerId)},
{enabled: Boolean(ownerId)}
);
const ownerTeam = teams?.json.at(0);

const teamActor = ownerId
? {type: 'team' as Actor['type'], id: ownerId, name: ownerTeam?.name ?? ''}
: null;

const rule: MetricAlert = {
type: CombinedAlertType.METRIC,
...item.alertRule,
latestIncident: item,
};

return (
<div
css={[
listItemGridCss,
css`
grid-template-areas:
'badge name time'
'badge message message'
'. icons icons';
grid-template-columns: max-content 1fr max-content;
gap: var(--space25) var(--space100);
`,
]}
>
<div style={{gridArea: 'badge'}}>
<AlertBadge status={item.status} isIssue={false} />
</div>

<div
css={[gridFlexEndCss, xSmallCss]}
style={{gridArea: 'time', color: 'var(--gray300)'}}
>
<TimeSince date={item.dateStarted} unitStyle="extraShort" />
</div>

<TextOverflow css={smallCss} style={{gridArea: 'name'}}>
<SentryAppLink
to={{
url: alertDetailsLink({slug: organizationSlug} as Organization, item),
query: {alert: item.identifier},
}}
onClick={() => {
trackAnalytics?.({
eventKey: `devtoolbar.alert-list.item.click`,
eventName: `devtoolbar: Click alert-list item`,
});
}}
>
<strong>{item.title}</strong>
</SentryAppLink>
</TextOverflow>

<div css={smallCss} style={{gridArea: 'message'}}>
<AlertRuleStatus rule={rule} />
</div>

{teamActor ? (
<div
css={[
badgeWithLabelCss,
xSmallCss,
css`
justify-self: flex-end;
`,
]}
style={{gridArea: 'icons'}}
>
<ActorAvatar actor={teamActor} size={16} hasTooltip={false} />{' '}
<TextOverflow>{teamActor.name}</TextOverflow>
</div>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {useMemo} from 'react';

import type {Incident} from 'sentry/views/alerts/types';

import useConfiguration from '../../hooks/useConfiguration';
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
import type {ApiEndpointQueryKey} from '../../types';

export default function useInfiniteFeedbackList() {
const {organizationSlug, projectId} = useConfiguration();

return useFetchInfiniteApiData<Incident[]>({
queryKey: useMemo(
(): ApiEndpointQueryKey => [
'io.sentry.toolbar',
`/organizations/${organizationSlug}/incidents/`,
{
query: {
limit: 25,
queryReferrer: 'devtoolbar',
project: [projectId],
statsPeriod: '14d',
status: 'open',
},
},
],
[organizationSlug, projectId]
),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {useMemo} from 'react';

import useConfiguration from '../../hooks/useConfiguration';
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
import type {FeedbackIssueListItem} from '../../types';
import type {ApiEndpointQueryKey, FeedbackIssueListItem} from '../../types';

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

return useFetchInfiniteApiData<FeedbackIssueListItem[]>({
queryKey: useMemo(
() => [
(): ApiEndpointQueryKey => [
'io.sentry.toolbar',
`/organizations/${organizationSlug}/issues/`,
{
query: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function FeedbackPanel() {
query: `url:*${transactionName}`,
});

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

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {IssueCategory} from 'sentry/types/group';

import useConfiguration from '../../hooks/useConfiguration';
import useFetchInfiniteApiData from '../../hooks/useFetchInfiniteApiData';
import type {ApiEndpointQueryKey} from '../../types';

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

return useFetchInfiniteApiData<Group[]>({
queryKey: useMemo(
() => [
(): ApiEndpointQueryKey => [
'io.sentry.toolbar',
`/organizations/${organizationSlug}/issues/`,
{
query: {
Expand Down
7 changes: 4 additions & 3 deletions static/app/components/devtoolbar/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {css} from '@emotion/react';

import useConfiguration from 'sentry/components/devtoolbar/hooks/useConfiguration';
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
import {IconClose, IconIssues, IconMegaphone} from 'sentry/icons';
import {IconClose, IconIssues, IconMegaphone, IconSiren} from 'sentry/icons';

import usePlacementCss from '../hooks/usePlacementCss';
import useToolbarRoute from '../hooks/useToolbarRoute';
Expand All @@ -22,8 +22,9 @@ export default function Navigation({setIsHidden}: {setIsHidden: (val: boolean) =
placement.navigation.css,
]}
>
<NavButton panelName="issues" label={'Issues'} icon={<IconIssues />} />
<NavButton panelName="feedback" label={'User Feedback'} icon={<IconMegaphone />} />
<NavButton panelName="issues" label="Issues" icon={<IconIssues />} />
<NavButton panelName="feedback" label="User Feedback" icon={<IconMegaphone />} />
<NavButton panelName="alerts" label="Active Alerts" icon={<IconSiren />} />
<HideButton
onClick={() => {
setIsHidden(true);
Expand Down
3 changes: 3 additions & 0 deletions static/app/components/devtoolbar/components/panelRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import {lazy} from 'react';

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

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

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

switch (state.activePanel) {
case 'alerts':
return <PanelAlerts />;
case 'feedback':
return <PanelFeedback />;
case 'issues':
Expand Down
1 change: 1 addition & 0 deletions static/app/components/devtoolbar/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Props {

export default function Providers({children, config, container}: Props) {
const queryClient = useMemo(() => new QueryClient({}), []);

const myCache = useMemo(
() =>
createCache({
Expand Down
4 changes: 2 additions & 2 deletions static/app/components/devtoolbar/components/sentryAppLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export default function SentryAppLink({children, to, onClick}: Props) {
return (
<a
css={inlineLinkCss}
onClick={onClick}
href={url}
target="_blank"
onClick={onClick}
rel="noreferrer noopener"
target="_blank"
>
{children}
</a>
Expand Down
32 changes: 32 additions & 0 deletions static/app/components/devtoolbar/components/teams/useTeams.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {useMemo} from 'react';

import type {Team} from 'sentry/types/organization';

import useConfiguration from '../../hooks/useConfiguration';
import useFetchApiData from '../../hooks/useFetchApiData';
import type {ApiEndpointQueryKey} from '../../types';

interface Props {
idOrSlug?: string;
}

export default function useTeams({idOrSlug}: Props, opts?: {enabled: boolean}) {
const {organizationSlug} = useConfiguration();

return useFetchApiData<Team[]>({
queryKey: useMemo(
(): ApiEndpointQueryKey => [
'io.sentry.toolbar',
`/organizations/${organizationSlug}/teams/`,
{
query: {
query: `id:${idOrSlug}`,
},
},
],
[idOrSlug, organizationSlug]
),
cacheTime: Infinity,
enabled: opts?.enabled ?? true,
});
}
Loading

0 comments on commit 6bfcabd

Please sign in to comment.