import {useState} from 'react'; import styled from '@emotion/styled'; import memoize from 'lodash/memoize'; import Access from 'sentry/components/acl/access'; import AlertBadge from 'sentry/components/alertBadge'; import ActorAvatar from 'sentry/components/avatar/actorAvatar'; import TeamAvatar from 'sentry/components/avatar/teamAvatar'; import {openConfirmModal} from 'sentry/components/confirm'; import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; import DropdownBubble from 'sentry/components/dropdownBubble'; import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu'; import ErrorBoundary from 'sentry/components/errorBoundary'; import Highlight from 'sentry/components/highlight'; 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, IconUser} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {Actor, Project} from 'sentry/types'; import type {ColorOrAlias} from 'sentry/utils/theme'; import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants'; import { AlertRuleComparisonType, AlertRuleThresholdType, AlertRuleTriggerType, } from 'sentry/views/alerts/rules/metric/types'; import {CombinedAlertType, CombinedMetricIssueAlerts, 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; // Set of team ids that the user belongs to userTeams: Set; }; /** * Memoized function to find a project from a list of projects */ const getProject = memoize((slug: string, projects: Project[]) => projects.find(project => project.slug === slug) ); function RuleListRow({ rule, projectsLoaded, projects, orgId, onDelete, onOwnerChange, userTeams, hasEditAccess, }: Props) { 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 renderAlertRuleStatus(): React.ReactNode { if (isIssueAlert(rule)) { return null; } 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.has(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: tct( "Are you sure you want to delete [name]? You won't be able to view the history of this alert once it's deleted.", {name: 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 = { value: '', label: () => ( {t('Unassigned')} ), searchKey: 'unassigned', actor: '', disabled: false, }; const projectRow = projects.filter(project => project.slug === slug); const projectRowTeams = projectRow[0].teams; const filteredProjectTeams = projectRowTeams?.filter(projTeam => { return userTeams.has(projTeam.id); }); const dropdownTeams = filteredProjectTeams ?.map((team, idx) => ({ value: team.id, searchKey: team.slug, label: ({inputValue}) => ( ), })) .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']} /> )} ); } const FlexCenter = styled('div')` display: flex; align-items: center; `; const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>` display: flex; align-items: center; gap: ${space(2)}; position: relative; ${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}; @media (max-width: ${p => p.theme.breakpoints.xlarge}) { max-width: 300px; } @media (max-width: ${p => p.theme.breakpoints.large}) { max-width: 165px; } @media (max-width: ${p => p.theme.breakpoints.medium}) { max-width: 100px; } `; 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 StyledIconUser = styled(IconUser)` /* We need this to center with Avatar */ margin-right: 2px; `; const IconContainer = styled('div')` display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex-shrink: 0; `; const MenuItemWrapper = styled('div')` display: flex; align-items: center; font-size: 13px; `; const Label = styled(TextOverflow)` margin-left: 6px; `; export default RuleListRow;