import {Fragment, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import moment from 'moment'; import type {APIRequestMethod} from 'sentry/api'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import Input from 'sentry/components/input'; import {IconAdd, IconClose, IconDelete, IconEdit} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {ActivationConditionType, MonitorType} from 'sentry/types/alerts'; import type {Project} from 'sentry/types/project'; import {getExactDuration, parseLargestSuffix} from 'sentry/utils/formatters'; import {capitalize} from 'sentry/utils/string/capitalize'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import { // ActionType, AlertRuleThresholdType, AlertRuleTriggerType, Dataset, EventTypes, // TargetType, type UnsavedMetricRule, } from 'sentry/views/alerts/rules/metric/types'; import {MEPAlertsQueryType} from 'sentry/views/alerts/wizard/options'; import { CRASH_FREE_SESSION_RATE_STR, CRASH_FREE_USER_RATE_STR as _CRASH_FREE_USER_RATE_STR, FAILURE_RATE_STR as _FAILURE_RATE_STR, NEW_ISSUE_COUNT_STR, NEW_THRESHOLD_PREFIX, REGRESSED_ISSUE_COUNT_STR as _REGRESSED_ISSUE_COUNT_STR, TOTAL_ERROR_COUNT_STR, UNHANDLED_ISSUE_COUNT_STR as _UNHANDLED_ISSUE_COUNT_STR, } from '../utils/constants'; import type {EditingThreshold, Threshold} from '../utils/types'; export type ThresholdGroupRowsProps = { allEnvironmentNames: string[]; project: Project; refetch: () => void; setTempError: (msg: string) => void; isLastRow?: boolean; newGroup?: boolean; onFormClose?: (id: string) => void; threshold?: Threshold; }; export function ThresholdGroupRows({ allEnvironmentNames, isLastRow = false, newGroup = false, onFormClose, project, refetch, setTempError, threshold: initialThreshold, }: ThresholdGroupRowsProps) { const [editingThresholds, setEditingThresholds] = useState<{ [key: string]: EditingThreshold; }>(() => { const editingThreshold = {}; if (newGroup) { const [windowValue, windowSuffix] = parseLargestSuffix(0); const id = `${NEW_THRESHOLD_PREFIX}`; const newGroupEdit = { id, project, windowValue, windowSuffix, threshold_type: 'total_error_count', trigger_type: 'over', value: 0, hasError: false, }; editingThreshold[id] = newGroupEdit; } return editingThreshold; }); const [newThresholdIterator, setNewThresholdIterator] = useState(0); // used simply to initialize new threshold const api = useApi(); const organization = useOrganization(); const isActivatedAlert = organization.features?.includes('activated-alert-rules'); const thresholdIdSet = useMemo(() => { const initial = new Set([]); if (initialThreshold) initial.add(initialThreshold.id); return new Set([...initial, ...Object.keys(editingThresholds)]); }, [initialThreshold, editingThresholds]); const thresholdTypeList = useMemo(() => { const isInternal = organization.features?.includes('releases-v2-internal'); const list = [ { value: TOTAL_ERROR_COUNT_STR, textValue: 'Errors', label: 'Error Count', }, ]; if (isInternal) { list.push( { value: CRASH_FREE_SESSION_RATE_STR, textValue: 'Crash Free Sessions', label: 'Crash Free Sessions', }, { value: NEW_ISSUE_COUNT_STR, textValue: 'New Issue Count', label: 'New Issue Count', } ); } return list; }, [organization]); const windowOptions = thresholdType => { let options = [ { value: 'hours', textValue: 'hours', label: 'hrs', }, { value: 'days', textValue: 'days', label: 'days', }, ]; if (thresholdType !== CRASH_FREE_SESSION_RATE_STR) { options = [ { value: 'seconds', textValue: 'seconds', label: 's', }, { value: 'minutes', textValue: 'minutes', label: 'min', }, ...options, ]; } return options; }; const initializeNewThreshold = ( environmentName: string | undefined = undefined, defaultWindow: number = 0 ) => { if (!project) { setTempError('No project provided'); return; } const thresholdId = `${NEW_THRESHOLD_PREFIX}-${newThresholdIterator}`; const [windowValue, windowSuffix] = parseLargestSuffix(defaultWindow); const newThreshold: EditingThreshold = { id: thresholdId, project, environmentName, windowValue, windowSuffix, threshold_type: 'total_error_count', trigger_type: 'over', value: 0, hasError: false, }; const updatedEditingThresholds = {...editingThresholds}; updatedEditingThresholds[thresholdId] = newThreshold; setEditingThresholds(updatedEditingThresholds); setNewThresholdIterator(newThresholdIterator + 1); }; const enableEditThreshold = (threshold: Threshold) => { const updatedEditingThresholds = {...editingThresholds}; const [windowValue, windowSuffix] = parseLargestSuffix(threshold.window_in_seconds); updatedEditingThresholds[threshold.id] = { ...JSON.parse(JSON.stringify(threshold)), // Deep copy the original threshold object environmentName: threshold.environment ? threshold.environment.name : '', // convert environment to string for editing windowValue, windowSuffix, hasError: false, }; setEditingThresholds(updatedEditingThresholds); }; const saveMetricAlert = ( thresholdData: EditingThreshold, method: APIRequestMethod = 'POST' ) => { const slug = project.slug; const windowMinutes = moment .duration(thresholdData.windowValue, thresholdData.windowSuffix) .as('seconds') / 60; /* Convert threshold data structure to metric alert data structure */ const metricAlertData: UnsavedMetricRule & {name: string} = { name: `Release Alert Rule for ${slug} in ${thresholdData.environmentName}`, monitorType: MonitorType.ACTIVATED, aggregate: 'count()', dataset: Dataset.ERRORS, environment: thresholdData.environmentName || null, projects: [slug], query: '', resolveThreshold: null, thresholdPeriod: 1, thresholdType: AlertRuleThresholdType.ABOVE, timeWindow: windowMinutes, triggers: [ { label: AlertRuleTriggerType.CRITICAL, alertThreshold: thresholdData.value, // TODO - add a default action to triggers actions: [], }, ], comparisonDelta: null, eventTypes: [EventTypes.ERROR], owner: null, queryType: MEPAlertsQueryType.ERROR, activationCondition: ActivationConditionType.RELEASE_CREATION, }; let apiUrl = `/organizations/${organization.slug}/alert-rules/`; if (!thresholdData.id.includes(NEW_THRESHOLD_PREFIX)) { apiUrl += `${thresholdData.id}/`; } const metricAlertRequest = api.requestPromise(apiUrl, { method, data: metricAlertData, }); return metricAlertRequest; }; const saveReleaseThreshold = ( thresholdData: EditingThreshold, method: APIRequestMethod = 'POST' ) => { let apiUrl = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/`; if (!thresholdData.id.includes(NEW_THRESHOLD_PREFIX)) { apiUrl += `${thresholdData.id}/`; } const releaseRequest = api.requestPromise(apiUrl, { method, data: thresholdData, }); return releaseRequest; }; const saveThreshold = (saveIds: string[]) => { saveIds.forEach(id => { const thresholdData = editingThresholds[id]; const method = id.includes(NEW_THRESHOLD_PREFIX) ? 'POST' : 'PUT'; const seconds = moment .duration(thresholdData.windowValue, thresholdData.windowSuffix) .as('seconds'); if (!thresholdData.project) { setTempError('Project required'); return; } const submitData = { ...thresholdData, environment: thresholdData.environmentName, window_in_seconds: seconds, }; const request = isActivatedAlert ? saveMetricAlert(submitData, method) : saveReleaseThreshold(submitData, method); request .then(() => { refetch(); closeEditForm(id); }) .catch(_err => { setTempError('Issue saving threshold'); setEditingThresholds(prevState => { const errorThreshold = { ...submitData, hasError: true, }; const updatedEditingThresholds = {...prevState}; updatedEditingThresholds[id] = errorThreshold; return updatedEditingThresholds; }); }); }); }; const deleteThreshold = thresholdId => { const updatedEditingThresholds = {...editingThresholds}; const thresholdData = editingThresholds[thresholdId]; const method = 'DELETE'; let path = `/projects/${organization.slug}/${thresholdData.project.slug}/release-thresholds/${thresholdId}/`; if (isActivatedAlert) path = `/organizations/${organization.slug}/alert-rules/${thresholdId}/`; if (!thresholdId.includes(NEW_THRESHOLD_PREFIX)) { const request = api.requestPromise(path, { method, }); request.then(refetch).catch(_err => { setTempError('Issue deleting threshold'); const errorThreshold = { ...thresholdData, hasError: true, }; updatedEditingThresholds[thresholdId] = errorThreshold as EditingThreshold; setEditingThresholds(updatedEditingThresholds); }); } delete updatedEditingThresholds[thresholdId]; setEditingThresholds(updatedEditingThresholds); }; const closeEditForm = thresholdId => { const updatedEditingThresholds = {...editingThresholds}; delete updatedEditingThresholds[thresholdId]; setEditingThresholds(updatedEditingThresholds); onFormClose?.(thresholdId); }; const editThresholdState = (thresholdId, key, value) => { if (editingThresholds[thresholdId]) { const updateEditing = JSON.parse(JSON.stringify(editingThresholds)); const currentThresholdValues = updateEditing[thresholdId]; updateEditing[thresholdId][key] = value; if (key === 'threshold_type' && value === CRASH_FREE_SESSION_RATE_STR) { if (['seconds', 'minutes'].indexOf(currentThresholdValues.windowSuffix) > -1) { updateEditing[thresholdId].windowSuffix = 'hours'; } } setEditingThresholds(updateEditing); } }; return ( {Array.from(thresholdIdSet).map((tId: string, idx: number) => { const isEditing = tId in editingThresholds; // NOTE: we're casting the threshold type because we can't dynamically derive type below const threshold = isEditing ? (editingThresholds[tId] as EditingThreshold) : (initialThreshold as Threshold); return ( {/* ENV ONLY EDITABLE IF NEW */} {!initialThreshold || threshold.id !== initialThreshold.id ? ( editThresholdState( threshold.id, 'environmentName', selectedOption.value ) } options={[ { value: '', textValue: '', label: '', }, ...allEnvironmentNames.map(env => ({ value: env, textValue: env, label: env, })), ]} /> ) : ( {/* '' means it _has_ an environment, but the env has no name */} {(threshold as Threshold).environment ? (threshold as Threshold).environment.name || '' : '{No environment}'} )} {/* FOLLOWING COLUMNS ARE EDITABLE */} {isEditing ? ( editThresholdState(threshold.id, 'windowValue', e.target.value) } /> editThresholdState( threshold.id, 'windowSuffix', selectedOption.value ) } options={windowOptions(threshold.threshold_type)} /> editThresholdState( threshold.id, 'threshold_type', selectedOption.value ) } options={thresholdTypeList} /> {threshold.trigger_type === 'over' ? ( ) : ( )} editThresholdState(threshold.id, 'value', e.target.value) } /> ) : ( {getExactDuration( (threshold as Threshold).window_in_seconds || 0, false, 'seconds' )}
{threshold.threshold_type .split('_') .map(word => capitalize(word)) .join(' ')}
 {threshold.trigger_type === 'over' ? '>' : '<'} 
{threshold.value}
)} {/* END OF EDITABLE COLUMNS */} {isEditing ? ( {!threshold.id.includes(NEW_THRESHOLD_PREFIX) && (