row.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import memoize from 'lodash/memoize';
  4. import Access from 'sentry/components/acl/access';
  5. import MenuItemActionLink from 'sentry/components/actions/menuItemActionLink';
  6. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  7. import Button from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import Confirm from 'sentry/components/confirm';
  10. import DateTime from 'sentry/components/dateTime';
  11. import DropdownLink from 'sentry/components/dropdownLink';
  12. import ErrorBoundary from 'sentry/components/errorBoundary';
  13. import IdBadge from 'sentry/components/idBadge';
  14. import Link from 'sentry/components/links/link';
  15. import TimeSince from 'sentry/components/timeSince';
  16. import Tooltip from 'sentry/components/tooltip';
  17. import {IconArrow, IconDelete, IconEllipsis, IconSettings} from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import overflowEllipsis from 'sentry/styles/overflowEllipsis';
  20. import space from 'sentry/styles/space';
  21. import {Actor, Organization, Project} from 'sentry/types';
  22. import getDynamicText from 'sentry/utils/getDynamicText';
  23. import type {Color} from 'sentry/utils/theme';
  24. import {AlertRuleThresholdType} from 'sentry/views/alerts/incidentRules/types';
  25. import AlertBadge from '../alertBadge';
  26. import {CombinedMetricIssueAlerts, IncidentStatus} from '../types';
  27. import {isIssueAlert} from '../utils';
  28. type Props = {
  29. rule: CombinedMetricIssueAlerts;
  30. projects: Project[];
  31. projectsLoaded: boolean;
  32. orgId: string;
  33. organization: Organization;
  34. onDelete: (projectId: string, rule: CombinedMetricIssueAlerts) => void;
  35. // Set of team ids that the user belongs to
  36. userTeams: Set<string>;
  37. };
  38. /**
  39. * Memoized function to find a project from a list of projects
  40. */
  41. const getProject = memoize((slug: string, projects: Project[]) =>
  42. projects.find(project => project.slug === slug)
  43. );
  44. function RuleListRow({
  45. rule,
  46. projectsLoaded,
  47. projects,
  48. orgId,
  49. onDelete,
  50. userTeams,
  51. }: Props) {
  52. const activeIncident =
  53. rule.latestIncident?.status !== undefined &&
  54. [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes(
  55. rule.latestIncident.status
  56. );
  57. function renderLastIncidentDate(): React.ReactNode {
  58. if (isIssueAlert(rule)) {
  59. return null;
  60. }
  61. if (!rule.latestIncident) {
  62. return '-';
  63. }
  64. if (activeIncident) {
  65. return (
  66. <div>
  67. {t('Triggered ')}
  68. <TimeSince date={rule.latestIncident.dateCreated} />
  69. </div>
  70. );
  71. }
  72. return (
  73. <div>
  74. {t('Resolved ')}
  75. <TimeSince date={rule.latestIncident.dateClosed!} />
  76. </div>
  77. );
  78. }
  79. function renderAlertRuleStatus(): React.ReactNode {
  80. if (isIssueAlert(rule)) {
  81. return null;
  82. }
  83. const criticalTrigger = rule.triggers.find(({label}) => label === 'critical');
  84. const warningTrigger = rule.triggers.find(({label}) => label === 'warning');
  85. const resolvedTrigger = rule.resolveThreshold;
  86. const trigger =
  87. activeIncident && rule.latestIncident?.status === IncidentStatus.CRITICAL
  88. ? criticalTrigger
  89. : warningTrigger ?? criticalTrigger;
  90. let iconColor: Color = 'green300';
  91. let iconDirection: 'up' | 'down' | undefined;
  92. let thresholdTypeText =
  93. activeIncident && rule.thresholdType === AlertRuleThresholdType.ABOVE
  94. ? t('Above')
  95. : t('Below');
  96. if (activeIncident) {
  97. iconColor =
  98. trigger?.label === 'critical'
  99. ? 'red300'
  100. : trigger?.label === 'warning'
  101. ? 'yellow300'
  102. : 'green300';
  103. iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down';
  104. } else {
  105. // Use the Resolved threshold type, which is opposite of Critical
  106. iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up';
  107. thresholdTypeText =
  108. rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above');
  109. }
  110. return (
  111. <FlexCenter>
  112. <IconArrow color={iconColor} direction={iconDirection} />
  113. <TriggerText>
  114. {`${thresholdTypeText} ${
  115. rule.latestIncident || (!rule.latestIncident && !resolvedTrigger)
  116. ? trigger?.alertThreshold?.toLocaleString()
  117. : resolvedTrigger?.toLocaleString()
  118. }`}
  119. </TriggerText>
  120. </FlexCenter>
  121. );
  122. }
  123. const slug = rule.projects[0];
  124. const editLink = `/organizations/${orgId}/alerts/${
  125. isIssueAlert(rule) ? 'rules' : 'metric-rules'
  126. }/${slug}/${rule.id}/`;
  127. const detailsLink = `/organizations/${orgId}/alerts/rules/details/${rule.id}/`;
  128. const ownerId = rule.owner?.split(':')[1];
  129. const teamActor = ownerId
  130. ? {type: 'team' as Actor['type'], id: ownerId, name: ''}
  131. : null;
  132. const canEdit = ownerId ? userTeams.has(ownerId) : true;
  133. const alertLink = isIssueAlert(rule) ? (
  134. rule.name
  135. ) : (
  136. <TitleLink to={isIssueAlert(rule) ? editLink : detailsLink}>{rule.name}</TitleLink>
  137. );
  138. const IssueStatusText: Record<IncidentStatus, string> = {
  139. [IncidentStatus.CRITICAL]: t('Critical'),
  140. [IncidentStatus.WARNING]: t('Warning'),
  141. [IncidentStatus.CLOSED]: t('Resolved'),
  142. [IncidentStatus.OPENED]: t('Resolved'),
  143. };
  144. return (
  145. <ErrorBoundary>
  146. <AlertNameWrapper isIssueAlert={isIssueAlert(rule)}>
  147. <FlexCenter>
  148. <Tooltip
  149. title={
  150. isIssueAlert(rule)
  151. ? t('Issue Alert')
  152. : tct('Metric Alert Status: [status]', {
  153. status:
  154. IssueStatusText[
  155. rule?.latestIncident?.status ?? IncidentStatus.CLOSED
  156. ],
  157. })
  158. }
  159. >
  160. <AlertBadge
  161. status={rule?.latestIncident?.status}
  162. isIssue={isIssueAlert(rule)}
  163. hideText
  164. />
  165. </Tooltip>
  166. </FlexCenter>
  167. <AlertNameAndStatus>
  168. <AlertName>{alertLink}</AlertName>
  169. {!isIssueAlert(rule) && renderLastIncidentDate()}
  170. </AlertNameAndStatus>
  171. </AlertNameWrapper>
  172. <FlexCenter>{renderAlertRuleStatus()}</FlexCenter>
  173. <FlexCenter>
  174. <ProjectBadgeContainer>
  175. <ProjectBadge
  176. avatarSize={18}
  177. project={!projectsLoaded ? {slug} : getProject(slug, projects)}
  178. />
  179. </ProjectBadgeContainer>
  180. </FlexCenter>
  181. <FlexCenter>
  182. {teamActor ? <ActorAvatar actor={teamActor} size={24} /> : '-'}
  183. </FlexCenter>
  184. <FlexCenter>
  185. <StyledDateTime
  186. date={getDynamicText({
  187. value: rule.dateCreated,
  188. fixed: new Date('2021-04-20'),
  189. })}
  190. format="ll"
  191. />
  192. </FlexCenter>
  193. <ActionsRow>
  194. <Access access={['alerts:write']}>
  195. {({hasAccess}) => (
  196. <React.Fragment>
  197. <StyledDropdownLink>
  198. <DropdownLink
  199. anchorRight
  200. caret={false}
  201. title={
  202. <Button
  203. tooltipProps={{
  204. containerDisplayMode: 'flex',
  205. }}
  206. size="small"
  207. type="button"
  208. aria-label={t('Show more')}
  209. icon={<IconEllipsis size="xs" />}
  210. />
  211. }
  212. >
  213. <li>
  214. <Link to={editLink}>{t('Edit')}</Link>
  215. </li>
  216. <Confirm
  217. disabled={!hasAccess || !canEdit}
  218. message={tct(
  219. "Are you sure you want to delete [name]? You won't be able to view the history of this alert once it's deleted.",
  220. {
  221. name: rule.name,
  222. }
  223. )}
  224. header={t('Delete Alert Rule?')}
  225. priority="danger"
  226. confirmText={t('Delete Rule')}
  227. onConfirm={() => onDelete(slug, rule)}
  228. >
  229. <MenuItemActionLink title={t('Delete')}>
  230. {t('Delete')}
  231. </MenuItemActionLink>
  232. </Confirm>
  233. </DropdownLink>
  234. </StyledDropdownLink>
  235. {/* Small screen actions */}
  236. <StyledButtonBar gap={1}>
  237. <Confirm
  238. disabled={!hasAccess || !canEdit}
  239. message={tct(
  240. "Are you sure you want to delete [name]? You won't be able to view the history of this alert once it's deleted.",
  241. {
  242. name: rule.name,
  243. }
  244. )}
  245. header={t('Delete Alert Rule?')}
  246. priority="danger"
  247. confirmText={t('Delete Rule')}
  248. onConfirm={() => onDelete(slug, rule)}
  249. >
  250. <Button
  251. type="button"
  252. icon={<IconDelete />}
  253. size="small"
  254. title={t('Delete')}
  255. />
  256. </Confirm>
  257. <Tooltip title={t('Edit')}>
  258. <Button
  259. size="small"
  260. type="button"
  261. icon={<IconSettings />}
  262. to={editLink}
  263. />
  264. </Tooltip>
  265. </StyledButtonBar>
  266. </React.Fragment>
  267. )}
  268. </Access>
  269. </ActionsRow>
  270. </ErrorBoundary>
  271. );
  272. }
  273. const TitleLink = styled(Link)`
  274. ${overflowEllipsis}
  275. `;
  276. const FlexCenter = styled('div')`
  277. display: flex;
  278. align-items: center;
  279. `;
  280. const AlertNameWrapper = styled(FlexCenter)<{isIssueAlert?: boolean}>`
  281. ${p => p.isIssueAlert && `padding: ${space(3)} ${space(2)}; line-height: 2.4;`}
  282. `;
  283. const AlertNameAndStatus = styled('div')`
  284. ${overflowEllipsis}
  285. margin-left: ${space(1.5)};
  286. line-height: 1.35;
  287. `;
  288. const AlertName = styled('div')`
  289. ${overflowEllipsis}
  290. font-size: ${p => p.theme.fontSizeLarge};
  291. @media (max-width: ${p => p.theme.breakpoints[3]}) {
  292. max-width: 300px;
  293. }
  294. @media (max-width: ${p => p.theme.breakpoints[2]}) {
  295. max-width: 165px;
  296. }
  297. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  298. max-width: 100px;
  299. }
  300. `;
  301. const ProjectBadgeContainer = styled('div')`
  302. width: 100%;
  303. `;
  304. const ProjectBadge = styled(IdBadge)`
  305. flex-shrink: 0;
  306. `;
  307. const StyledDateTime = styled(DateTime)`
  308. font-variant-numeric: tabular-nums;
  309. `;
  310. const TriggerText = styled('div')`
  311. margin-left: ${space(1)};
  312. white-space: nowrap;
  313. font-variant-numeric: tabular-nums;
  314. `;
  315. const StyledButtonBar = styled(ButtonBar)`
  316. display: none;
  317. justify-content: flex-start;
  318. align-items: center;
  319. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  320. display: flex;
  321. }
  322. `;
  323. const StyledDropdownLink = styled('div')`
  324. display: none;
  325. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  326. display: block;
  327. }
  328. `;
  329. const ActionsRow = styled(FlexCenter)`
  330. justify-content: center;
  331. padding: ${space(1)};
  332. `;
  333. export default RuleListRow;