thresholdGroupRows.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment';
  4. import type {APIRequestMethod} from 'sentry/api';
  5. import {Button} from 'sentry/components/button';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import Input from 'sentry/components/input';
  8. import {IconAdd, IconClose, IconDelete, IconEdit} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {ActivationConditionType, MonitorType} from 'sentry/types/alerts';
  12. import type {Project} from 'sentry/types/project';
  13. import {getExactDuration, parseLargestSuffix} from 'sentry/utils/formatters';
  14. import {capitalize} from 'sentry/utils/string/capitalize';
  15. import useApi from 'sentry/utils/useApi';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import {
  18. // ActionType,
  19. AlertRuleThresholdType,
  20. AlertRuleTriggerType,
  21. Dataset,
  22. EventTypes,
  23. // TargetType,
  24. type UnsavedMetricRule,
  25. } from 'sentry/views/alerts/rules/metric/types';
  26. import {MEPAlertsQueryType} from 'sentry/views/alerts/wizard/options';
  27. import {
  28. CRASH_FREE_SESSION_RATE_STR,
  29. CRASH_FREE_USER_RATE_STR as _CRASH_FREE_USER_RATE_STR,
  30. FAILURE_RATE_STR as _FAILURE_RATE_STR,
  31. NEW_ISSUE_COUNT_STR,
  32. NEW_THRESHOLD_PREFIX,
  33. REGRESSED_ISSUE_COUNT_STR as _REGRESSED_ISSUE_COUNT_STR,
  34. TOTAL_ERROR_COUNT_STR,
  35. UNHANDLED_ISSUE_COUNT_STR as _UNHANDLED_ISSUE_COUNT_STR,
  36. } from '../utils/constants';
  37. import type {EditingThreshold, Threshold} from '../utils/types';
  38. export type ThresholdGroupRowsProps = {
  39. allEnvironmentNames: string[];
  40. project: Project;
  41. refetch: () => void;
  42. setTempError: (msg: string) => void;
  43. isLastRow?: boolean;
  44. newGroup?: boolean;
  45. onFormClose?: (id: string) => void;
  46. threshold?: Threshold;
  47. };
  48. export function ThresholdGroupRows({
  49. allEnvironmentNames,
  50. isLastRow = false,
  51. newGroup = false,
  52. onFormClose,
  53. project,
  54. refetch,
  55. setTempError,
  56. threshold: initialThreshold,
  57. }: ThresholdGroupRowsProps) {
  58. const [editingThresholds, setEditingThresholds] = useState<{
  59. [key: string]: EditingThreshold;
  60. }>(() => {
  61. const editingThreshold = {};
  62. if (newGroup) {
  63. const [windowValue, windowSuffix] = parseLargestSuffix(0);
  64. const id = `${NEW_THRESHOLD_PREFIX}`;
  65. const newGroupEdit = {
  66. id,
  67. project,
  68. windowValue,
  69. windowSuffix,
  70. threshold_type: 'total_error_count',
  71. trigger_type: 'over',
  72. value: 0,
  73. hasError: false,
  74. };
  75. editingThreshold[id] = newGroupEdit;
  76. }
  77. return editingThreshold;
  78. });
  79. const [newThresholdIterator, setNewThresholdIterator] = useState<number>(0); // used simply to initialize new threshold
  80. const api = useApi();
  81. const organization = useOrganization();
  82. const isActivatedAlert = organization.features?.includes('activated-alert-rules');
  83. const thresholdIdSet = useMemo(() => {
  84. const initial = new Set<string>([]);
  85. if (initialThreshold) initial.add(initialThreshold.id);
  86. return new Set([...initial, ...Object.keys(editingThresholds)]);
  87. }, [initialThreshold, editingThresholds]);
  88. const thresholdTypeList = useMemo(() => {
  89. const isInternal = organization.features?.includes('releases-v2-internal');
  90. const list = [
  91. {
  92. value: TOTAL_ERROR_COUNT_STR,
  93. textValue: 'Errors',
  94. label: 'Error Count',
  95. },
  96. ];
  97. if (isInternal) {
  98. list.push(
  99. {
  100. value: CRASH_FREE_SESSION_RATE_STR,
  101. textValue: 'Crash Free Sessions',
  102. label: 'Crash Free Sessions',
  103. },
  104. {
  105. value: NEW_ISSUE_COUNT_STR,
  106. textValue: 'New Issue Count',
  107. label: 'New Issue Count',
  108. }
  109. );
  110. }
  111. return list;
  112. }, [organization]);
  113. const windowOptions = thresholdType => {
  114. let options = [
  115. {
  116. value: 'hours',
  117. textValue: 'hours',
  118. label: 'hrs',
  119. },
  120. {
  121. value: 'days',
  122. textValue: 'days',
  123. label: 'days',
  124. },
  125. ];
  126. if (thresholdType !== CRASH_FREE_SESSION_RATE_STR) {
  127. options = [
  128. {
  129. value: 'seconds',
  130. textValue: 'seconds',
  131. label: 's',
  132. },
  133. {
  134. value: 'minutes',
  135. textValue: 'minutes',
  136. label: 'min',
  137. },
  138. ...options,
  139. ];
  140. }
  141. return options;
  142. };
  143. const initializeNewThreshold = (
  144. environmentName: string | undefined = undefined,
  145. defaultWindow: number = 0
  146. ) => {
  147. if (!project) {
  148. setTempError('No project provided');
  149. return;
  150. }
  151. const thresholdId = `${NEW_THRESHOLD_PREFIX}-${newThresholdIterator}`;
  152. const [windowValue, windowSuffix] = parseLargestSuffix(defaultWindow);
  153. const newThreshold: EditingThreshold = {
  154. id: thresholdId,
  155. project,
  156. environmentName,
  157. windowValue,
  158. windowSuffix,
  159. threshold_type: 'total_error_count',
  160. trigger_type: 'over',
  161. value: 0,
  162. hasError: false,
  163. };
  164. const updatedEditingThresholds = {...editingThresholds};
  165. updatedEditingThresholds[thresholdId] = newThreshold;
  166. setEditingThresholds(updatedEditingThresholds);
  167. setNewThresholdIterator(newThresholdIterator + 1);
  168. };
  169. const enableEditThreshold = (threshold: Threshold) => {
  170. const updatedEditingThresholds = {...editingThresholds};
  171. const [windowValue, windowSuffix] = parseLargestSuffix(threshold.window_in_seconds);
  172. updatedEditingThresholds[threshold.id] = {
  173. ...JSON.parse(JSON.stringify(threshold)), // Deep copy the original threshold object
  174. environmentName: threshold.environment ? threshold.environment.name : '', // convert environment to string for editing
  175. windowValue,
  176. windowSuffix,
  177. hasError: false,
  178. };
  179. setEditingThresholds(updatedEditingThresholds);
  180. };
  181. const saveMetricAlert = (
  182. thresholdData: EditingThreshold,
  183. method: APIRequestMethod = 'POST'
  184. ) => {
  185. const slug = project.slug;
  186. const windowMinutes =
  187. moment
  188. .duration(thresholdData.windowValue, thresholdData.windowSuffix)
  189. .as('seconds') / 60;
  190. /* Convert threshold data structure to metric alert data structure */
  191. const metricAlertData: UnsavedMetricRule & {name: string} = {
  192. name: `Release Alert Rule for ${slug} in ${thresholdData.environmentName}`,
  193. monitorType: MonitorType.ACTIVATED,
  194. aggregate: 'count()',
  195. dataset: Dataset.ERRORS,
  196. environment: thresholdData.environmentName || null,
  197. projects: [slug],
  198. query: '',
  199. resolveThreshold: null,
  200. thresholdPeriod: 1,
  201. thresholdType: AlertRuleThresholdType.ABOVE,
  202. timeWindow: windowMinutes,
  203. triggers: [
  204. {
  205. label: AlertRuleTriggerType.CRITICAL,
  206. alertThreshold: thresholdData.value,
  207. // TODO - add a default action to triggers
  208. actions: [],
  209. },
  210. ],
  211. comparisonDelta: null,
  212. eventTypes: [EventTypes.ERROR],
  213. owner: null,
  214. queryType: MEPAlertsQueryType.ERROR,
  215. activationCondition: ActivationConditionType.RELEASE_CREATION,
  216. };
  217. let apiUrl = `/organizations/${organization.slug}/alert-rules/`;
  218. if (!thresholdData.id.includes(NEW_THRESHOLD_PREFIX)) {
  219. apiUrl += `${thresholdData.id}/`;
  220. }
  221. const metricAlertRequest = api.requestPromise(apiUrl, {
  222. method,
  223. data: metricAlertData,
  224. });
  225. return metricAlertRequest;
  226. };
  227. const saveReleaseThreshold = (
  228. thresholdData: EditingThreshold,
  229. method: APIRequestMethod = 'POST'
  230. ) => {
  231. let apiUrl = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/`;
  232. if (!thresholdData.id.includes(NEW_THRESHOLD_PREFIX)) {
  233. apiUrl += `${thresholdData.id}/`;
  234. }
  235. const releaseRequest = api.requestPromise(apiUrl, {
  236. method,
  237. data: thresholdData,
  238. });
  239. return releaseRequest;
  240. };
  241. const saveThreshold = (saveIds: string[]) => {
  242. saveIds.forEach(id => {
  243. const thresholdData = editingThresholds[id];
  244. const method = id.includes(NEW_THRESHOLD_PREFIX) ? 'POST' : 'PUT';
  245. const seconds = moment
  246. .duration(thresholdData.windowValue, thresholdData.windowSuffix)
  247. .as('seconds');
  248. if (!thresholdData.project) {
  249. setTempError('Project required');
  250. return;
  251. }
  252. const submitData = {
  253. ...thresholdData,
  254. environment: thresholdData.environmentName,
  255. window_in_seconds: seconds,
  256. };
  257. const request = isActivatedAlert
  258. ? saveMetricAlert(submitData, method)
  259. : saveReleaseThreshold(submitData, method);
  260. request
  261. .then(() => {
  262. refetch();
  263. closeEditForm(id);
  264. })
  265. .catch(_err => {
  266. setTempError('Issue saving threshold');
  267. setEditingThresholds(prevState => {
  268. const errorThreshold = {
  269. ...submitData,
  270. hasError: true,
  271. };
  272. const updatedEditingThresholds = {...prevState};
  273. updatedEditingThresholds[id] = errorThreshold;
  274. return updatedEditingThresholds;
  275. });
  276. });
  277. });
  278. };
  279. const deleteThreshold = thresholdId => {
  280. const updatedEditingThresholds = {...editingThresholds};
  281. const thresholdData = editingThresholds[thresholdId];
  282. const method = 'DELETE';
  283. let path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${thresholdId}/`;
  284. if (isActivatedAlert)
  285. path = `/organizations/${organization.slug}/alert-rules/${thresholdId}/`;
  286. if (!thresholdId.includes(NEW_THRESHOLD_PREFIX)) {
  287. const request = api.requestPromise(path, {
  288. method,
  289. });
  290. request.then(refetch).catch(_err => {
  291. setTempError('Issue deleting threshold');
  292. const errorThreshold = {
  293. ...thresholdData,
  294. hasError: true,
  295. };
  296. updatedEditingThresholds[thresholdId] = errorThreshold as EditingThreshold;
  297. setEditingThresholds(updatedEditingThresholds);
  298. });
  299. }
  300. delete updatedEditingThresholds[thresholdId];
  301. setEditingThresholds(updatedEditingThresholds);
  302. };
  303. const closeEditForm = thresholdId => {
  304. const updatedEditingThresholds = {...editingThresholds};
  305. delete updatedEditingThresholds[thresholdId];
  306. setEditingThresholds(updatedEditingThresholds);
  307. onFormClose?.(thresholdId);
  308. };
  309. const editThresholdState = (thresholdId, key, value) => {
  310. if (editingThresholds[thresholdId]) {
  311. const updateEditing = JSON.parse(JSON.stringify(editingThresholds));
  312. const currentThresholdValues = updateEditing[thresholdId];
  313. updateEditing[thresholdId][key] = value;
  314. if (key === 'threshold_type' && value === CRASH_FREE_SESSION_RATE_STR) {
  315. if (['seconds', 'minutes'].indexOf(currentThresholdValues.windowSuffix) > -1) {
  316. updateEditing[thresholdId].windowSuffix = 'hours';
  317. }
  318. }
  319. setEditingThresholds(updateEditing);
  320. }
  321. };
  322. return (
  323. <StyledThresholdGroup>
  324. {Array.from(thresholdIdSet).map((tId: string, idx: number) => {
  325. const isEditing = tId in editingThresholds;
  326. // NOTE: we're casting the threshold type because we can't dynamically derive type below
  327. const threshold = isEditing
  328. ? (editingThresholds[tId] as EditingThreshold)
  329. : (initialThreshold as Threshold);
  330. return (
  331. <StyledRow
  332. key={threshold.id}
  333. lastRow={isLastRow && idx === thresholdIdSet.size - 1}
  334. hasError={isEditing && (threshold as EditingThreshold).hasError}
  335. >
  336. {/* ENV ONLY EDITABLE IF NEW */}
  337. {!initialThreshold || threshold.id !== initialThreshold.id ? (
  338. <CompactSelect
  339. style={{width: '100%'}}
  340. value={(threshold as EditingThreshold).environmentName || ''}
  341. onChange={selectedOption =>
  342. editThresholdState(
  343. threshold.id,
  344. 'environmentName',
  345. selectedOption.value
  346. )
  347. }
  348. options={[
  349. {
  350. value: '',
  351. textValue: '',
  352. label: '',
  353. },
  354. ...allEnvironmentNames.map(env => ({
  355. value: env,
  356. textValue: env,
  357. label: env,
  358. })),
  359. ]}
  360. />
  361. ) : (
  362. <FlexCenter>
  363. {/* '' means it _has_ an environment, but the env has no name */}
  364. {(threshold as Threshold).environment
  365. ? (threshold as Threshold).environment.name || ''
  366. : '{No environment}'}
  367. </FlexCenter>
  368. )}
  369. {/* FOLLOWING COLUMNS ARE EDITABLE */}
  370. {isEditing ? (
  371. <Fragment>
  372. <FlexCenter>
  373. <Input
  374. style={{width: '50%'}}
  375. value={(threshold as EditingThreshold).windowValue}
  376. type="number"
  377. min={0}
  378. onChange={e =>
  379. editThresholdState(threshold.id, 'windowValue', e.target.value)
  380. }
  381. />
  382. <CompactSelect
  383. style={{width: '50%'}}
  384. value={(threshold as EditingThreshold).windowSuffix}
  385. onChange={selectedOption =>
  386. editThresholdState(
  387. threshold.id,
  388. 'windowSuffix',
  389. selectedOption.value
  390. )
  391. }
  392. options={windowOptions(threshold.threshold_type)}
  393. />
  394. </FlexCenter>
  395. <FlexCenter>
  396. <CompactSelect
  397. value={threshold.threshold_type}
  398. onChange={selectedOption =>
  399. editThresholdState(
  400. threshold.id,
  401. 'threshold_type',
  402. selectedOption.value
  403. )
  404. }
  405. options={thresholdTypeList}
  406. />
  407. {threshold.trigger_type === 'over' ? (
  408. <Button
  409. onClick={() =>
  410. editThresholdState(threshold.id, 'trigger_type', 'under')
  411. }
  412. >
  413. &gt;
  414. </Button>
  415. ) : (
  416. <Button
  417. onClick={() =>
  418. editThresholdState(threshold.id, 'trigger_type', 'over')
  419. }
  420. >
  421. &lt;
  422. </Button>
  423. )}
  424. <Input
  425. value={threshold.value}
  426. type="number"
  427. min={0}
  428. onChange={e =>
  429. editThresholdState(threshold.id, 'value', e.target.value)
  430. }
  431. />
  432. </FlexCenter>
  433. </Fragment>
  434. ) : (
  435. <Fragment>
  436. <FlexCenter>
  437. {getExactDuration(
  438. (threshold as Threshold).window_in_seconds || 0,
  439. false,
  440. 'seconds'
  441. )}
  442. </FlexCenter>
  443. <FlexCenter>
  444. <div>
  445. {threshold.threshold_type
  446. .split('_')
  447. .map(word => capitalize(word))
  448. .join(' ')}
  449. </div>
  450. <div>&nbsp;{threshold.trigger_type === 'over' ? '>' : '<'}&nbsp;</div>
  451. <div>{threshold.value}</div>
  452. </FlexCenter>
  453. </Fragment>
  454. )}
  455. {/* END OF EDITABLE COLUMNS */}
  456. <ActionsColumn>
  457. {isEditing ? (
  458. <Fragment>
  459. <Button size="xs" onClick={() => saveThreshold([threshold.id])}>
  460. Save
  461. </Button>
  462. {!threshold.id.includes(NEW_THRESHOLD_PREFIX) && (
  463. <Button
  464. aria-label={t('Delete threshold')}
  465. borderless
  466. icon={<IconDelete color="danger" />}
  467. onClick={() => deleteThreshold(threshold.id)}
  468. size="xs"
  469. />
  470. )}
  471. <Button
  472. aria-label={t('Close')}
  473. borderless
  474. icon={<IconClose />}
  475. onClick={() => closeEditForm(threshold.id)}
  476. size="xs"
  477. />
  478. </Fragment>
  479. ) : (
  480. <Fragment>
  481. <Button
  482. aria-label={t('Edit threshold')}
  483. icon={<IconEdit />}
  484. onClick={() => enableEditThreshold(threshold as Threshold)}
  485. size="xs"
  486. />
  487. <Button
  488. aria-label={t('New Threshold')}
  489. icon={<IconAdd color="activeText" isCircled />}
  490. onClick={() =>
  491. initializeNewThreshold(
  492. initialThreshold?.environment
  493. ? initialThreshold.environment.name
  494. : undefined,
  495. initialThreshold ? initialThreshold.window_in_seconds : 0
  496. )
  497. }
  498. size="xs"
  499. />
  500. </Fragment>
  501. )}
  502. </ActionsColumn>
  503. </StyledRow>
  504. );
  505. })}
  506. </StyledThresholdGroup>
  507. );
  508. }
  509. const StyledThresholdGroup = styled('div')`
  510. display: contents;
  511. `;
  512. type StyledThresholdRowProps = {
  513. lastRow: boolean;
  514. hasError?: boolean;
  515. };
  516. const StyledRow = styled('div')<StyledThresholdRowProps>`
  517. display: contents;
  518. > * {
  519. padding: ${space(2)};
  520. background-color: ${p =>
  521. p.hasError ? 'rgba(255, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0)'};
  522. border-bottom: ${p => (p.lastRow ? 0 : '1px solid ' + p.theme.border)};
  523. }
  524. `;
  525. const FlexCenter = styled('div')`
  526. display: flex;
  527. align-items: center;
  528. > * {
  529. margin: 0 ${space(1)};
  530. }
  531. `;
  532. const ActionsColumn = styled('div')`
  533. display: flex;
  534. align-items: center;
  535. justify-content: space-around;
  536. `;