import {useState} from 'react'; import styled from '@emotion/styled'; import Access from 'sentry/components/acl/access'; import ActorAvatar from 'sentry/components/avatar/actorAvatar'; import TeamAvatar from 'sentry/components/avatar/teamAvatar'; import AlertBadge from 'sentry/components/badge/alertBadge'; import {openConfirmModal} from 'sentry/components/confirm'; import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; import type {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types'; import DropdownBubble from 'sentry/components/dropdownBubble'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import ErrorBoundary from 'sentry/components/errorBoundary'; import IdBadge from 'sentry/components/idBadge'; import Link from 'sentry/components/links/link'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import TextOverflow from 'sentry/components/textOverflow'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; import { IconArrow, IconChevron, IconEllipsis, IconMute, IconNot, IconUser, } from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Actor, Project} from 'sentry/types'; import type {ColorOrAlias} from 'sentry/utils/theme'; import {useUserTeams} from 'sentry/utils/useUserTeams'; import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants'; import { AlertRuleComparisonType, AlertRuleThresholdType, AlertRuleTriggerType, } from 'sentry/views/alerts/rules/metric/types'; import type {CombinedMetricIssueAlerts} from '../../types'; import {CombinedAlertType, IncidentStatus} from '../../types'; import {isIssueAlert} from '../../utils'; type Props = { hasEditAccess: boolean; onDelete: (projectId: string, rule: CombinedMetricIssueAlerts) => void; onOwnerChange: ( projectId: string, rule: CombinedMetricIssueAlerts, ownerValue: string ) => void; orgId: string; projects: Project[]; projectsLoaded: boolean; rule: CombinedMetricIssueAlerts; }; function RuleListRow({ rule, projectsLoaded, projects, orgId, onDelete, onOwnerChange, hasEditAccess, }: Props) { const {teams: userTeams} = useUserTeams(); const [assignee, setAssignee] = useState(''); const activeIncident = rule.latestIncident?.status !== undefined && [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes( rule.latestIncident.status ); function renderLastIncidentDate(): React.ReactNode { if (isIssueAlert(rule)) { if (!rule.lastTriggered) { return t('Alert not triggered yet'); } return (
{t('Triggered ')}
); } if (!rule.latestIncident) { return t('Alert not triggered yet'); } if (activeIncident) { return (
{t('Triggered ')}
); } return (
{t('Resolved ')}
); } function renderSnoozeStatus(): React.ReactNode { return ( {t('Muted')} ); } function renderAlertRuleStatus(): React.ReactNode { if (isIssueAlert(rule)) { if (rule.status === 'disabled') { return ( {t('Disabled')} ); } if (rule.snooze) { return renderSnoozeStatus(); } return null; } if (rule.snooze) { return renderSnoozeStatus(); } const criticalTrigger = rule.triggers.find( ({label}) => label === AlertRuleTriggerType.CRITICAL ); const warningTrigger = rule.triggers.find( ({label}) => label === AlertRuleTriggerType.WARNING ); const resolvedTrigger = rule.resolveThreshold; const trigger = activeIncident && rule.latestIncident?.status === IncidentStatus.CRITICAL ? criticalTrigger : warningTrigger ?? criticalTrigger; let iconColor: ColorOrAlias = 'successText'; let iconDirection: 'up' | 'down' | undefined; let thresholdTypeText = activeIncident && rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below'); if (activeIncident) { iconColor = trigger?.label === AlertRuleTriggerType.CRITICAL ? 'errorText' : trigger?.label === AlertRuleTriggerType.WARNING ? 'warningText' : 'successText'; iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down'; } else { // Use the Resolved threshold type, which is opposite of Critical iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up'; thresholdTypeText = rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above'); } return ( {`${thresholdTypeText} ${ rule.latestIncident || (!rule.latestIncident && !resolvedTrigger) ? trigger?.alertThreshold?.toLocaleString() : resolvedTrigger?.toLocaleString() }`} {getThresholdUnits( rule.aggregate, rule.comparisonDelta ? AlertRuleComparisonType.CHANGE : AlertRuleComparisonType.COUNT )} ); } const slug = rule.projects[0]; const editLink = `/organizations/${orgId}/alerts/${ isIssueAlert(rule) ? 'rules' : 'metric-rules' }/${slug}/${rule.id}/`; const duplicateLink = { pathname: `/organizations/${orgId}/alerts/new/${ rule.type === CombinedAlertType.METRIC ? 'metric' : 'issue' }/`, query: { project: slug, duplicateRuleId: rule.id, createFromDuplicate: true, referrer: 'alert_stream', }, }; const ownerId = rule.owner?.split(':')[1]; const teamActor = ownerId ? {type: 'team' as Actor['type'], id: ownerId, name: ''} : null; const canEdit = ownerId ? userTeams.some(team => team.id === ownerId) : true; const IssueStatusText: Record = { [IncidentStatus.CRITICAL]: t('Critical'), [IncidentStatus.WARNING]: t('Warning'), [IncidentStatus.CLOSED]: t('Resolved'), [IncidentStatus.OPENED]: t('Resolved'), }; const actions: MenuItemProps[] = [ { key: 'edit', label: t('Edit'), to: editLink, }, { key: 'duplicate', label: t('Duplicate'), to: duplicateLink, }, { key: 'delete', label: t('Delete'), priority: 'danger', onAction: () => { openConfirmModal({ onConfirm: () => onDelete(slug, rule), header:
{t('Delete Alert Rule?')}
, message: t( 'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.', rule.name ), confirmText: t('Delete Rule'), priority: 'danger', }); }, }, ]; function handleOwnerChange({value}: {value: string}) { const ownerValue = value && `team:${value}`; setAssignee(ownerValue); onOwnerChange(slug, rule, ownerValue); } const unassignedOption: ItemsBeforeFilter[number] = { value: '', label: ( ), searchKey: 'unassigned', actor: '', disabled: false, }; const project = projects.find(p => p.slug === slug); const filteredProjectTeams = (project?.teams ?? []).filter(projTeam => { return userTeams.some(team => team.id === projTeam.id); }); const dropdownTeams = filteredProjectTeams .map((team, idx) => ({ value: team.id, searchKey: team.slug, label: ( ), })) .concat(unassignedOption); const teamId = assignee?.split(':')[1]; const teamName = filteredProjectTeams.find(team => team.id === teamId); const assigneeTeamActor = assignee && { type: 'team' as Actor['type'], id: teamId, name: '', }; const avatarElement = assigneeTeamActor ? ( ) : ( ); return ( {rule.name} {renderLastIncidentDate()} {renderAlertRuleStatus()} {teamActor ? ( ) : ( {!projectsLoaded && } {projectsLoaded && ( { e?.stopPropagation(); }} items={dropdownTeams} alignMenu="right" onSelect={handleOwnerChange} itemSize="small" searchPlaceholder={t('Filter teams')} disableLabelPadding emptyHidesInput disabled={!hasEditAccess} > {({getActorProps, isOpen}) => ( {avatarElement} {hasEditAccess && ( )} )} )} )} {({hasAccess}) => ( , showChevron: false, }} disabledKeys={hasAccess && canEdit ? [] : ['delete']} /> )} ); } // TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component const FlexCenter = styled('div')` display: flex; align-items: center; `; const IssueAlertStatusWrapper = styled('div')` display: flex; align-items: center; gap: ${space(1)}; line-height: 2; `; const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>` ${p => p.theme.overflowEllipsis} display: flex; align-items: center; gap: ${space(2)}; ${p => p.isIssueAlert && `padding: ${space(3)} ${space(2)}; line-height: 2.4;`} `; const AlertNameAndStatus = styled('div')` ${p => p.theme.overflowEllipsis} line-height: 1.35; `; const AlertName = styled('div')` ${p => p.theme.overflowEllipsis} font-size: ${p => p.theme.fontSizeLarge}; `; const AlertIncidentDate = styled('div')` color: ${p => p.theme.gray300}; `; const ProjectBadgeContainer = styled('div')` width: 100%; `; const ProjectBadge = styled(IdBadge)` flex-shrink: 0; `; const TriggerText = styled('div')` margin-left: ${space(1)}; white-space: nowrap; font-variant-numeric: tabular-nums; `; const ActionsColumn = styled('div')` display: flex; align-items: center; justify-content: center; padding: ${space(1)}; `; const AssigneeWrapper = styled('div')` display: flex; justify-content: flex-end; /* manually align menu underneath dropdown caret */ ${DropdownBubble} { right: -14px; } `; const DropdownButton = styled('div')` display: flex; align-items: center; font-size: 20px; `; const StyledChevron = styled(IconChevron)` margin-left: ${space(1)}; `; const PaddedIconUser = styled(IconUser)` padding: ${space(0.25)}; `; const IconContainer = styled('div')` display: flex; align-items: center; justify-content: center; width: ${p => p.theme.iconSizes.lg}; height: ${p => p.theme.iconSizes.lg}; flex-shrink: 0; `; const MenuItemWrapper = styled('div')` display: flex; align-items: center; font-size: ${p => p.theme.fontSizeSmall}; `; const Label = styled(TextOverflow)` margin-left: ${space(0.75)}; `; const MarginLeft = styled('div')` margin-left: ${space(1)}; `; const StyledLoadingIndicator = styled(LoadingIndicator)` height: 24px; margin: 0; margin-right: ${space(1.5)}; `; export default RuleListRow;