|
@@ -1,196 +1,144 @@
|
|
|
import styled from '@emotion/styled';
|
|
|
-import {Location} from 'history';
|
|
|
+import type {Location} from 'history';
|
|
|
import pick from 'lodash/pick';
|
|
|
|
|
|
import AlertBadge from 'sentry/components/alertBadge';
|
|
|
import {SectionHeading} from 'sentry/components/charts/styles';
|
|
|
-import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
|
|
import EmptyStateWarning from 'sentry/components/emptyStateWarning';
|
|
|
import Link from 'sentry/components/links/link';
|
|
|
+import LoadingError from 'sentry/components/loadingError';
|
|
|
import Placeholder from 'sentry/components/placeholder';
|
|
|
import TimeSince from 'sentry/components/timeSince';
|
|
|
import {URL_PARAM} from 'sentry/constants/pageFilters';
|
|
|
import {IconCheckmark, IconExclamation, IconFire, IconOpen} from 'sentry/icons';
|
|
|
-import {t, tct} from 'sentry/locale';
|
|
|
+import {t} from 'sentry/locale';
|
|
|
import {space} from 'sentry/styles/space';
|
|
|
-import {Organization} from 'sentry/types';
|
|
|
-
|
|
|
-import {Incident, IncidentStatus} from '../alerts/types';
|
|
|
+import type {Organization} from 'sentry/types';
|
|
|
+import {useApiQuery} from 'sentry/utils/queryClient';
|
|
|
+import {Incident, IncidentStatus} from 'sentry/views/alerts/types';
|
|
|
|
|
|
import MissingAlertsButtons from './missingFeatureButtons/missingAlertsButtons';
|
|
|
import {SectionHeadingLink, SectionHeadingWrapper, SidebarSection} from './styles';
|
|
|
-import {didProjectOrEnvironmentChange} from './utils';
|
|
|
|
|
|
const PLACEHOLDER_AND_EMPTY_HEIGHT = '172px';
|
|
|
|
|
|
-type Props = DeprecatedAsyncComponent['props'] & {
|
|
|
+interface AlertRowProps {
|
|
|
+ alert: Incident;
|
|
|
+ orgSlug: string;
|
|
|
+}
|
|
|
+
|
|
|
+function AlertRow({alert, orgSlug}: AlertRowProps) {
|
|
|
+ const {status, identifier, title, dateClosed, dateStarted} = alert;
|
|
|
+ const isResolved = status === IncidentStatus.CLOSED;
|
|
|
+ const isWarning = status === IncidentStatus.WARNING;
|
|
|
+
|
|
|
+ const Icon = isResolved ? IconCheckmark : isWarning ? IconExclamation : IconFire;
|
|
|
+
|
|
|
+ const statusProps = {isResolved, isWarning};
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AlertRowLink
|
|
|
+ aria-label={title}
|
|
|
+ to={`/organizations/${orgSlug}/alerts/${identifier}/`}
|
|
|
+ >
|
|
|
+ <AlertBadgeWrapper {...statusProps} icon={Icon}>
|
|
|
+ <AlertBadge status={status} />
|
|
|
+ </AlertBadgeWrapper>
|
|
|
+ <AlertDetails>
|
|
|
+ <AlertTitle>{title}</AlertTitle>
|
|
|
+ <AlertDate {...statusProps}>
|
|
|
+ {isResolved ? t('Resolved') : t('Triggered')}{' '}
|
|
|
+ {isResolved ? (
|
|
|
+ dateClosed ? (
|
|
|
+ <TimeSince date={dateClosed} />
|
|
|
+ ) : null
|
|
|
+ ) : (
|
|
|
+ <TimeSince
|
|
|
+ date={dateStarted}
|
|
|
+ tooltipUnderlineColor={getStatusColor(statusProps)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </AlertDate>
|
|
|
+ </AlertDetails>
|
|
|
+ </AlertRowLink>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+interface ProjectLatestAlertsProps {
|
|
|
isProjectStabilized: boolean;
|
|
|
location: Location;
|
|
|
organization: Organization;
|
|
|
projectSlug: string;
|
|
|
-};
|
|
|
-
|
|
|
-type State = {
|
|
|
- resolvedAlerts: Incident[] | null;
|
|
|
- unresolvedAlerts: Incident[] | null;
|
|
|
- hasAlertRule?: boolean;
|
|
|
-} & DeprecatedAsyncComponent['state'];
|
|
|
-
|
|
|
-class ProjectLatestAlerts extends DeprecatedAsyncComponent<Props, State> {
|
|
|
- shouldComponentUpdate(nextProps: Props, nextState: State) {
|
|
|
- const {location, isProjectStabilized} = this.props;
|
|
|
- // TODO(project-detail): we temporarily removed refetching based on timeselector
|
|
|
- if (
|
|
|
- this.state !== nextState ||
|
|
|
- didProjectOrEnvironmentChange(location, nextProps.location) ||
|
|
|
- isProjectStabilized !== nextProps.isProjectStabilized
|
|
|
- ) {
|
|
|
- return true;
|
|
|
- }
|
|
|
-
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- componentDidUpdate(prevProps: Props) {
|
|
|
- const {location, isProjectStabilized} = this.props;
|
|
|
-
|
|
|
- if (
|
|
|
- didProjectOrEnvironmentChange(prevProps.location, location) ||
|
|
|
- prevProps.isProjectStabilized !== isProjectStabilized
|
|
|
- ) {
|
|
|
- this.remountComponent();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
|
|
|
- const {location, organization, isProjectStabilized} = this.props;
|
|
|
-
|
|
|
- if (!isProjectStabilized) {
|
|
|
- return [];
|
|
|
- }
|
|
|
-
|
|
|
- const query = {
|
|
|
- ...pick(location.query, Object.values(URL_PARAM)),
|
|
|
- per_page: 3,
|
|
|
- };
|
|
|
-
|
|
|
- // we are listing 3 alerts total, first unresolved and then we fill with resolved
|
|
|
- return [
|
|
|
- [
|
|
|
- 'unresolvedAlerts',
|
|
|
- `/organizations/${organization.slug}/incidents/`,
|
|
|
- {query: {...query, status: 'open'}},
|
|
|
- ],
|
|
|
- [
|
|
|
- 'resolvedAlerts',
|
|
|
- `/organizations/${organization.slug}/incidents/`,
|
|
|
- {query: {...query, status: 'closed'}},
|
|
|
- ],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * If our alerts are empty, determine if we've configured alert rules (empty message differs then)
|
|
|
- */
|
|
|
- async onLoadAllEndpointsSuccess() {
|
|
|
- const {unresolvedAlerts, resolvedAlerts} = this.state;
|
|
|
- const {location, organization, isProjectStabilized} = this.props;
|
|
|
-
|
|
|
- if (!isProjectStabilized) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if ([...(unresolvedAlerts ?? []), ...(resolvedAlerts ?? [])].length !== 0) {
|
|
|
- this.setState({hasAlertRule: true});
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- this.setState({loading: true});
|
|
|
+}
|
|
|
|
|
|
- const alertRules = await this.api.requestPromise(
|
|
|
+function ProjectLatestAlerts({
|
|
|
+ location,
|
|
|
+ organization,
|
|
|
+ isProjectStabilized,
|
|
|
+ projectSlug,
|
|
|
+}: ProjectLatestAlertsProps) {
|
|
|
+ const query = {
|
|
|
+ ...pick(location.query, Object.values(URL_PARAM)),
|
|
|
+ per_page: 3,
|
|
|
+ };
|
|
|
+ const {
|
|
|
+ data: unresolvedAlerts = [],
|
|
|
+ isLoading: unresolvedAlertsIsLoading,
|
|
|
+ isError: unresolvedAlertsIsError,
|
|
|
+ } = useApiQuery<Incident[]>(
|
|
|
+ [
|
|
|
+ `/organizations/${organization.slug}/incidents/`,
|
|
|
+ {query: {...query, status: 'open'}},
|
|
|
+ ],
|
|
|
+ {staleTime: 0, enabled: isProjectStabilized}
|
|
|
+ );
|
|
|
+ const {
|
|
|
+ data: resolvedAlerts = [],
|
|
|
+ isLoading: resolvedAlertsIsLoading,
|
|
|
+ isError: resolvedAlertsIsError,
|
|
|
+ } = useApiQuery<Incident[]>(
|
|
|
+ [
|
|
|
+ `/organizations/${organization.slug}/incidents/`,
|
|
|
+ {query: {...query, status: 'closed'}},
|
|
|
+ ],
|
|
|
+ {staleTime: 0, enabled: isProjectStabilized}
|
|
|
+ );
|
|
|
+
|
|
|
+ const alertsUnresolvedAndResolved = [...unresolvedAlerts, ...resolvedAlerts];
|
|
|
+ const shouldLoadAlertRules =
|
|
|
+ alertsUnresolvedAndResolved.length === 0 &&
|
|
|
+ !unresolvedAlertsIsLoading &&
|
|
|
+ !resolvedAlertsIsLoading;
|
|
|
+ // This is only used to determine if we should show the "Create Alert" button
|
|
|
+ const {data: alertRules = [], isLoading: alertRulesLoading} = useApiQuery<any[]>(
|
|
|
+ [
|
|
|
`/organizations/${organization.slug}/alert-rules/`,
|
|
|
{
|
|
|
- method: 'GET',
|
|
|
query: {
|
|
|
- ...pick(location.query, [...Object.values(URL_PARAM)]),
|
|
|
+ ...pick(location.query, Object.values(URL_PARAM)),
|
|
|
+ // Sort by name
|
|
|
+ asc: 1,
|
|
|
per_page: 1,
|
|
|
},
|
|
|
- }
|
|
|
- );
|
|
|
-
|
|
|
- this.setState({hasAlertRule: alertRules.length > 0, loading: false});
|
|
|
- }
|
|
|
-
|
|
|
- get alertsLink() {
|
|
|
- const {organization} = this.props;
|
|
|
-
|
|
|
- // as this is a link to latest alerts, we want to only preserve project and environment
|
|
|
- return {
|
|
|
- pathname: `/organizations/${organization.slug}/alerts/`,
|
|
|
- query: {
|
|
|
- statsPeriod: undefined,
|
|
|
- start: undefined,
|
|
|
- end: undefined,
|
|
|
- utc: undefined,
|
|
|
},
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- renderAlertRow = (alert: Incident) => {
|
|
|
- const {organization} = this.props;
|
|
|
- const {status, id, identifier, title, dateClosed, dateStarted} = alert;
|
|
|
- const isResolved = status === IncidentStatus.CLOSED;
|
|
|
- const isWarning = status === IncidentStatus.WARNING;
|
|
|
-
|
|
|
- const Icon = isResolved ? IconCheckmark : isWarning ? IconExclamation : IconFire;
|
|
|
-
|
|
|
- const statusProps = {isResolved, isWarning};
|
|
|
-
|
|
|
- return (
|
|
|
- <AlertRowLink
|
|
|
- aria-label={title}
|
|
|
- to={`/organizations/${organization.slug}/alerts/${identifier}/`}
|
|
|
- key={id}
|
|
|
- >
|
|
|
- <AlertBadgeWrapper {...statusProps} icon={Icon}>
|
|
|
- <AlertBadge status={status} />
|
|
|
- </AlertBadgeWrapper>
|
|
|
- <AlertDetails>
|
|
|
- <AlertTitle>{title}</AlertTitle>
|
|
|
- <AlertDate {...statusProps}>
|
|
|
- {isResolved
|
|
|
- ? tct('Resolved [date]', {
|
|
|
- date: dateClosed ? <TimeSince date={dateClosed} /> : null,
|
|
|
- })
|
|
|
- : tct('Triggered [date]', {
|
|
|
- date: (
|
|
|
- <TimeSince
|
|
|
- date={dateStarted}
|
|
|
- tooltipUnderlineColor={getStatusColor(statusProps)}
|
|
|
- />
|
|
|
- ),
|
|
|
- })}
|
|
|
- </AlertDate>
|
|
|
- </AlertDetails>
|
|
|
- </AlertRowLink>
|
|
|
- );
|
|
|
- };
|
|
|
+ ],
|
|
|
+ {
|
|
|
+ staleTime: 0,
|
|
|
+ enabled: shouldLoadAlertRules,
|
|
|
+ }
|
|
|
+ );
|
|
|
|
|
|
- renderInnerBody() {
|
|
|
- const {organization, projectSlug, isProjectStabilized} = this.props;
|
|
|
- const {loading, unresolvedAlerts, resolvedAlerts, hasAlertRule} = this.state;
|
|
|
- const alertsUnresolvedAndResolved = [
|
|
|
- ...(unresolvedAlerts ?? []),
|
|
|
- ...(resolvedAlerts ?? []),
|
|
|
- ];
|
|
|
- const checkingForAlertRules =
|
|
|
- alertsUnresolvedAndResolved.length === 0 && hasAlertRule === undefined;
|
|
|
- const showLoadingIndicator = loading || checkingForAlertRules || !isProjectStabilized;
|
|
|
+ function renderAlertRules() {
|
|
|
+ if (unresolvedAlertsIsError || resolvedAlertsIsError) {
|
|
|
+ return <LoadingError message={t('Unable to load latest alerts')} />;
|
|
|
+ }
|
|
|
|
|
|
- if (showLoadingIndicator) {
|
|
|
+ const isLoading = unresolvedAlertsIsLoading || resolvedAlertsIsLoading;
|
|
|
+ if (isLoading || (shouldLoadAlertRules && alertRulesLoading)) {
|
|
|
return <Placeholder height={PLACEHOLDER_AND_EMPTY_HEIGHT} />;
|
|
|
}
|
|
|
|
|
|
+ const hasAlertRule = alertsUnresolvedAndResolved.length > 0 || alertRules?.length > 0;
|
|
|
if (!hasAlertRule) {
|
|
|
return (
|
|
|
<MissingAlertsButtons organization={organization} projectSlug={projectSlug} />
|
|
@@ -203,27 +151,36 @@ class ProjectLatestAlerts extends DeprecatedAsyncComponent<Props, State> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- return alertsUnresolvedAndResolved.slice(0, 3).map(this.renderAlertRow);
|
|
|
- }
|
|
|
-
|
|
|
- renderLoading() {
|
|
|
- return this.renderBody();
|
|
|
+ return alertsUnresolvedAndResolved
|
|
|
+ .slice(0, 3)
|
|
|
+ .map(alert => (
|
|
|
+ <AlertRow key={alert.id} alert={alert} orgSlug={organization.slug} />
|
|
|
+ ));
|
|
|
}
|
|
|
|
|
|
- renderBody() {
|
|
|
- return (
|
|
|
- <SidebarSection>
|
|
|
- <SectionHeadingWrapper>
|
|
|
- <SectionHeading>{t('Latest Alerts')}</SectionHeading>
|
|
|
- <SectionHeadingLink to={this.alertsLink}>
|
|
|
- <IconOpen />
|
|
|
- </SectionHeadingLink>
|
|
|
- </SectionHeadingWrapper>
|
|
|
-
|
|
|
- <div>{this.renderInnerBody()}</div>
|
|
|
- </SidebarSection>
|
|
|
- );
|
|
|
- }
|
|
|
+ return (
|
|
|
+ <SidebarSection>
|
|
|
+ <SectionHeadingWrapper>
|
|
|
+ <SectionHeading>{t('Latest Alerts')}</SectionHeading>
|
|
|
+ {/* as this is a link to latest alerts, we want to only preserve project and environment */}
|
|
|
+ <SectionHeadingLink
|
|
|
+ to={{
|
|
|
+ pathname: `/organizations/${organization.slug}/alerts/`,
|
|
|
+ query: {
|
|
|
+ statsPeriod: undefined,
|
|
|
+ start: undefined,
|
|
|
+ end: undefined,
|
|
|
+ utc: undefined,
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconOpen aria-label={t('Metric Alert History')} />
|
|
|
+ </SectionHeadingLink>
|
|
|
+ </SectionHeadingWrapper>
|
|
|
+
|
|
|
+ <div>{renderAlertRules()}</div>
|
|
|
+ </SidebarSection>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
const AlertRowLink = styled(Link)`
|