row.tsx 11 KB

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