|
@@ -17,34 +17,34 @@ import LoadingIndicator from 'app/components/loadingIndicator';
|
|
|
import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
|
|
|
import Pagination from 'app/components/pagination';
|
|
|
import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
|
|
|
+import SearchBar from 'app/components/searchBar';
|
|
|
import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
|
|
|
import {IconCheckmark, IconInfo} from 'app/icons';
|
|
|
import {t, tct} from 'app/locale';
|
|
|
import space from 'app/styles/space';
|
|
|
-import {Organization, Project} from 'app/types';
|
|
|
+import {Organization, Project, Team} from 'app/types';
|
|
|
import {trackAnalyticsEvent} from 'app/utils/analytics';
|
|
|
import Projects from 'app/utils/projects';
|
|
|
import withOrganization from 'app/utils/withOrganization';
|
|
|
+import withTeams from 'app/utils/withTeams';
|
|
|
import EmptyMessage from 'app/views/settings/components/emptyMessage';
|
|
|
|
|
|
+import TeamFilter, {getTeamParams} from '../rules/teamFilter';
|
|
|
import {Incident} from '../types';
|
|
|
|
|
|
import AlertHeader from './header';
|
|
|
import Onboarding from './onboarding';
|
|
|
import AlertListRow from './row';
|
|
|
-import {TableLayout, TitleAndSparkLine} from './styles';
|
|
|
+import {TableLayout} from './styles';
|
|
|
|
|
|
const DEFAULT_QUERY_STATUS = 'open';
|
|
|
|
|
|
const DOCS_URL =
|
|
|
'https://docs.sentry.io/workflow/alerts-notifications/alerts/?_ga=2.21848383.580096147.1592364314-1444595810.1582160976';
|
|
|
|
|
|
-function getQueryStatus(status: any): 'open' | 'closed' {
|
|
|
- return ['open', 'closed'].includes(status) ? status : DEFAULT_QUERY_STATUS;
|
|
|
-}
|
|
|
-
|
|
|
type Props = RouteComponentProps<{orgId: string}, {}> & {
|
|
|
organization: Organization;
|
|
|
+ teams: Team[];
|
|
|
};
|
|
|
|
|
|
type State = {
|
|
@@ -65,22 +65,43 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
|
|
|
const {params, location, organization} = this.props;
|
|
|
const {query} = location;
|
|
|
- const status = getQueryStatus(query.status);
|
|
|
- const incidentsQuery = {
|
|
|
- ...query,
|
|
|
- ...(organization.features.includes('alert-details-redesign')
|
|
|
- ? {expand: ['original_alert_rule']}
|
|
|
- : {}),
|
|
|
- status,
|
|
|
- };
|
|
|
-
|
|
|
- return [
|
|
|
- [
|
|
|
- 'incidentList',
|
|
|
- `/organizations/${params && params.orgId}/incidents/`,
|
|
|
- {query: incidentsQuery},
|
|
|
- ],
|
|
|
- ];
|
|
|
+
|
|
|
+ const status = this.getQueryStatus(query.status);
|
|
|
+ // Filtering by one status, both does nothing
|
|
|
+ if (status.length === 1) {
|
|
|
+ query.status = status;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (organization.features.includes('team-alerts-ownership')) {
|
|
|
+ query.team = getTeamParams(query.team);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (organization.features.includes('alert-details-redesign')) {
|
|
|
+ query.expand = ['original_alert_rule'];
|
|
|
+ }
|
|
|
+
|
|
|
+ return [['incidentList', `/organizations/${params?.orgId}/incidents/`, {query}]];
|
|
|
+ }
|
|
|
+
|
|
|
+ getQueryStatus(status: string | string[]): string[] {
|
|
|
+ if (Array.isArray(status)) {
|
|
|
+ return status;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (status === '') {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ // No default status w/ alert-history-filters
|
|
|
+ const hasAlertHistoryFilters = this.props.organization.features.includes(
|
|
|
+ 'alert-history-filters'
|
|
|
+ );
|
|
|
+
|
|
|
+ return ['open', 'closed'].includes(status as string)
|
|
|
+ ? [status as string]
|
|
|
+ : hasAlertHistoryFilters
|
|
|
+ ? []
|
|
|
+ : [DEFAULT_QUERY_STATUS];
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -138,6 +159,66 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
this.setState({hasAlertRule, firstVisitShown, loading: false});
|
|
|
}
|
|
|
|
|
|
+ handleChangeSearch = (title: string) => {
|
|
|
+ const {router, location} = this.props;
|
|
|
+ const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
|
|
|
+ router.push({
|
|
|
+ pathname: location.pathname,
|
|
|
+ query: {
|
|
|
+ ...currentQuery,
|
|
|
+ title,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ handleChangeFilter = (sectionId: string, activeFilters: Set<string>) => {
|
|
|
+ const {router, location} = this.props;
|
|
|
+ const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
|
|
|
+
|
|
|
+ let team = currentQuery.team;
|
|
|
+ if (sectionId === 'teams') {
|
|
|
+ team = activeFilters.size ? [...activeFilters] : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ let status = currentQuery.status;
|
|
|
+ if (sectionId === 'status') {
|
|
|
+ status = activeFilters.size ? [...activeFilters] : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ router.push({
|
|
|
+ pathname: location.pathname,
|
|
|
+ query: {
|
|
|
+ ...currentQuery,
|
|
|
+ status,
|
|
|
+ // Preserve empty team query parameter
|
|
|
+ team: team.length === 0 ? '' : team,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ renderFilterBar() {
|
|
|
+ const {teams, location} = this.props;
|
|
|
+ const selectedTeams = new Set(getTeamParams(location.query.team));
|
|
|
+ const selectedStatus = new Set(this.getQueryStatus(location.query.status));
|
|
|
+
|
|
|
+ return (
|
|
|
+ <FilterWrapper>
|
|
|
+ <TeamFilter
|
|
|
+ showStatus
|
|
|
+ teams={teams}
|
|
|
+ selectedStatus={selectedStatus}
|
|
|
+ selectedTeams={selectedTeams}
|
|
|
+ handleChangeFilter={this.handleChangeFilter}
|
|
|
+ />
|
|
|
+ <StyledSearchBar
|
|
|
+ placeholder={t('Search by name')}
|
|
|
+ query={location.query?.name}
|
|
|
+ onSearch={this.handleChangeSearch}
|
|
|
+ />
|
|
|
+ </FilterWrapper>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
tryRenderOnboarding() {
|
|
|
const {firstVisitShown} = this.state;
|
|
|
const {organization} = this.props;
|
|
@@ -167,8 +248,7 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
}
|
|
|
|
|
|
tryRenderEmpty() {
|
|
|
- const {hasAlertRule, incidentList} = this.state;
|
|
|
- const status = getQueryStatus(this.props.location.query.status);
|
|
|
+ const {incidentList} = this.state;
|
|
|
|
|
|
if (!incidentList || incidentList.length > 0) {
|
|
|
return null;
|
|
@@ -178,13 +258,7 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
<EmptyMessage
|
|
|
size="medium"
|
|
|
icon={<IconCheckmark isCircled size="48" />}
|
|
|
- title={
|
|
|
- !hasAlertRule
|
|
|
- ? t('No metric alert rules exist for the selected projects.')
|
|
|
- : status === 'open'
|
|
|
- ? t('No unresolved metric alerts in the selected projects.')
|
|
|
- : t('No resolved metric alerts in the selected projects.')
|
|
|
- }
|
|
|
+ title={t('No incidents exist for the current query.')}
|
|
|
description={tct('Learn more about [link:Metric Alerts]', {
|
|
|
link: <ExternalLink href={DOCS_URL} />,
|
|
|
})}
|
|
@@ -211,25 +285,27 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
? true
|
|
|
: false;
|
|
|
const showLoadingIndicator = loading || checkingForAlertRules;
|
|
|
- const status = getQueryStatus(this.props.location.query.status);
|
|
|
|
|
|
return (
|
|
|
<Fragment>
|
|
|
{this.tryRenderOnboarding() ?? (
|
|
|
<Panel>
|
|
|
{!loading && (
|
|
|
- <StyledPanelHeader>
|
|
|
- <TableLayout status={status}>
|
|
|
- <PaddedTitleAndSparkLine status={status}>
|
|
|
- <div>{t('Alert')}</div>
|
|
|
- {status === 'open' && <div>{t('Graph')}</div>}
|
|
|
- </PaddedTitleAndSparkLine>
|
|
|
+ <PanelHeader>
|
|
|
+ <TableLayout>
|
|
|
+ <div>{t('Alert')}</div>
|
|
|
+ <div>{t('Alert Rule')}</div>
|
|
|
<div>{t('Project')}</div>
|
|
|
- <div>{t('Triggered')}</div>
|
|
|
- {status === 'closed' && <div>{t('Duration')}</div>}
|
|
|
- {status === 'closed' && <div>{t('Resolved')}</div>}
|
|
|
+ <div>
|
|
|
+ <Feature
|
|
|
+ features={['team-alerts-ownership']}
|
|
|
+ organization={organization}
|
|
|
+ >
|
|
|
+ {t('Team')}
|
|
|
+ </Feature>
|
|
|
+ </div>
|
|
|
</TableLayout>
|
|
|
- </StyledPanelHeader>
|
|
|
+ </PanelHeader>
|
|
|
)}
|
|
|
{showLoadingIndicator ? (
|
|
|
<LoadingIndicator />
|
|
@@ -245,7 +321,6 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
projects={projects as Project[]}
|
|
|
incident={incident}
|
|
|
orgId={orgId}
|
|
|
- filteredStatus={status}
|
|
|
organization={organization}
|
|
|
/>
|
|
|
))
|
|
@@ -262,20 +337,22 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
}
|
|
|
|
|
|
renderBody() {
|
|
|
- const {params, location, organization, router} = this.props;
|
|
|
+ const {params, organization, router, location} = this.props;
|
|
|
const {pathname, query} = location;
|
|
|
const {orgId} = params;
|
|
|
|
|
|
const openIncidentsQuery = omit({...query, status: 'open'}, 'cursor');
|
|
|
const closedIncidentsQuery = omit({...query, status: 'closed'}, 'cursor');
|
|
|
-
|
|
|
- const status = getQueryStatus(query.status);
|
|
|
+ const status = this.getQueryStatus(location.query.status)[0] || DEFAULT_QUERY_STATUS;
|
|
|
+ const hasAlertHistoryFilters = organization.features.includes(
|
|
|
+ 'alert-history-filters'
|
|
|
+ );
|
|
|
|
|
|
return (
|
|
|
<SentryDocumentTitle title={t('Alerts')} orgSlug={orgId}>
|
|
|
<GlobalSelectionHeader organization={organization} showDateSelector={false}>
|
|
|
<AlertHeader organization={organization} router={router} activeTab="stream" />
|
|
|
- <Layout.Body>
|
|
|
+ <StyledLayoutBody>
|
|
|
<Layout.Main fullWidth>
|
|
|
{!this.tryRenderOnboarding() && (
|
|
|
<Fragment>
|
|
@@ -283,31 +360,35 @@ class IncidentsList extends AsyncComponent<Props, State & AsyncComponent['state'
|
|
|
features={['alert-details-redesign']}
|
|
|
organization={organization}
|
|
|
>
|
|
|
- <Alert icon={<IconInfo />}>
|
|
|
- {t('This page only shows metric alerts that have been triggered.')}
|
|
|
- </Alert>
|
|
|
+ <StyledAlert icon={<IconInfo />}>
|
|
|
+ {t('This page only shows metric alerts.')}
|
|
|
+ </StyledAlert>
|
|
|
</Feature>
|
|
|
- <StyledButtonBar merged active={status}>
|
|
|
- <Button
|
|
|
- to={{pathname, query: openIncidentsQuery}}
|
|
|
- barId="open"
|
|
|
- size="small"
|
|
|
- >
|
|
|
- {t('Unresolved')}
|
|
|
- </Button>
|
|
|
- <Button
|
|
|
- to={{pathname, query: closedIncidentsQuery}}
|
|
|
- barId="closed"
|
|
|
- size="small"
|
|
|
- >
|
|
|
- {t('Resolved')}
|
|
|
- </Button>
|
|
|
- </StyledButtonBar>
|
|
|
+ {hasAlertHistoryFilters ? (
|
|
|
+ this.renderFilterBar()
|
|
|
+ ) : (
|
|
|
+ <StyledButtonBar merged active={status}>
|
|
|
+ <Button
|
|
|
+ to={{pathname, query: openIncidentsQuery}}
|
|
|
+ barId="open"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ {t('Unresolved')}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ to={{pathname, query: closedIncidentsQuery}}
|
|
|
+ barId="closed"
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ {t('Resolved')}
|
|
|
+ </Button>
|
|
|
+ </StyledButtonBar>
|
|
|
+ )}
|
|
|
</Fragment>
|
|
|
)}
|
|
|
{this.renderList()}
|
|
|
</Layout.Main>
|
|
|
- </Layout.Body>
|
|
|
+ </StyledLayoutBody>
|
|
|
</GlobalSelectionHeader>
|
|
|
</SentryDocumentTitle>
|
|
|
);
|
|
@@ -326,14 +407,12 @@ class IncidentsListContainer extends Component<Props> {
|
|
|
}
|
|
|
|
|
|
trackView() {
|
|
|
- const {location, organization} = this.props;
|
|
|
- const status = getQueryStatus(location.query.status);
|
|
|
+ const {organization} = this.props;
|
|
|
|
|
|
trackAnalyticsEvent({
|
|
|
eventKey: 'alert_stream.viewed',
|
|
|
eventName: 'Alert Stream: Viewed',
|
|
|
organization_id: organization.id,
|
|
|
- status,
|
|
|
});
|
|
|
}
|
|
|
|
|
@@ -368,13 +447,22 @@ const StyledButtonBar = styled(ButtonBar)`
|
|
|
margin-bottom: ${space(1)};
|
|
|
`;
|
|
|
|
|
|
-const PaddedTitleAndSparkLine = styled(TitleAndSparkLine)`
|
|
|
- padding-left: ${space(2)};
|
|
|
+const StyledAlert = styled(Alert)`
|
|
|
+ margin-bottom: ${space(1.5)};
|
|
|
+`;
|
|
|
+
|
|
|
+const FilterWrapper = styled('div')`
|
|
|
+ display: flex;
|
|
|
+ margin-bottom: ${space(1.5)};
|
|
|
+`;
|
|
|
+
|
|
|
+const StyledSearchBar = styled(SearchBar)`
|
|
|
+ flex-grow: 1;
|
|
|
+ margin-left: ${space(1.5)};
|
|
|
`;
|
|
|
|
|
|
-const StyledPanelHeader = styled(PanelHeader)`
|
|
|
- /* Match table row padding for the grid to align */
|
|
|
- padding: ${space(1.5)} ${space(2)} ${space(1.5)} 0;
|
|
|
+const StyledLayoutBody = styled(Layout.Body)`
|
|
|
+ margin-bottom: -20px;
|
|
|
`;
|
|
|
|
|
|
-export default withOrganization(IncidentsListContainer);
|
|
|
+export default withOrganization(withTeams(IncidentsListContainer));
|