import {Fragment, useRef} from 'react'; import styled from '@emotion/styled'; import {Observer} from 'mobx-react'; import Alert from 'sentry/components/alert'; import AlertLink from 'sentry/components/alertLink'; import {RadioOption} from 'sentry/components/forms/controls/radioGroup'; import NumberField from 'sentry/components/forms/fields/numberField'; import RadioField from 'sentry/components/forms/fields/radioField'; import SelectField from 'sentry/components/forms/fields/selectField'; import SentryMemberTeamSelectorField from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; import SentryProjectSelectorField from 'sentry/components/forms/fields/sentryProjectSelectorField'; import TextField from 'sentry/components/forms/fields/textField'; import Form, {FormProps} from 'sentry/components/forms/form'; import FormModel from 'sentry/components/forms/model'; import ExternalLink from 'sentry/components/links/externalLink'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; import Text from 'sentry/components/text'; import {timezoneOptions} from 'sentry/data/timezones'; import {t, tct, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {SelectValue} from 'sentry/types'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import slugify from 'sentry/utils/slugify'; import commonTheme from 'sentry/utils/theme'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {normalizeUrl} from 'sentry/utils/withDomainRequired'; import {crontabAsText} from 'sentry/views/monitors/utils'; import { IntervalConfig, Monitor, MonitorConfig, MonitorType, ScheduleType, } from '../types'; const SCHEDULE_OPTIONS: RadioOption[] = [ [ScheduleType.CRONTAB, t('Crontab')], [ScheduleType.INTERVAL, t('Interval')], ]; const DEFAULT_MONITOR_TYPE = 'cron_job'; const DEFAULT_CRONTAB = '0 0 * * *'; // Maps the value from the SentryMemberTeamSelectorField -> the expected alert // rule key and vice-versa. // // XXX(epurkhiser): For whatever reason the rules API wants the team and member // to be capitalized. const RULE_TARGET_MAP = {team: 'Team', member: 'Member'} as const; const RULES_SELECTOR_MAP = {Team: 'team', Member: 'member'} as const; export const DEFAULT_MAX_RUNTIME = 30; const getIntervals = (n: number): SelectValue[] => [ {value: 'minute', label: tn('minute', 'minutes', n)}, {value: 'hour', label: tn('hour', 'hours', n)}, {value: 'day', label: tn('day', 'days', n)}, {value: 'week', label: tn('week', 'weeks', n)}, {value: 'month', label: tn('month', 'months', n)}, {value: 'year', label: tn('year', 'years', n)}, ]; type Props = { apiEndpoint: string; apiMethod: FormProps['apiMethod']; onSubmitSuccess: FormProps['onSubmitSuccess']; monitor?: Monitor; submitLabel?: string; }; interface TransformedData extends Partial> { alertRule?: Partial; config?: Partial; } /** * Transform sub-fields for what the API expects */ function transformData(_data: Record, model: FormModel) { const result = model.fields.toJSON().reduce((data, [k, v]) => { data.config ??= {}; data.alertRule ??= {}; if (k === 'alertRule.targets') { const alertTargets = (v as string[] | undefined)?.map(item => { // See SentryMemberTeamSelectorField to understand why these are strings const [type, id] = item.split(':'); const targetType = RULE_TARGET_MAP[type]; return {targetType, targetIdentifier: Number(id)}; }); data.alertRule.targets = alertTargets; return data; } if (k === 'alertRule.environment') { const environment = v === '' ? undefined : (v as string); data.alertRule.environment = environment; return data; } if (k === 'config.schedule.frequency' || k === 'config.schedule.interval') { if (!Array.isArray(data.config.schedule)) { data.config.schedule = [1, 'hour']; } } if (Array.isArray(data.config.schedule) && k === 'config.schedule.frequency') { data.config.schedule[0] = parseInt(v as string, 10); return data; } if (Array.isArray(data.config.schedule) && k === 'config.schedule.interval') { data.config.schedule[1] = v as IntervalConfig['schedule'][1]; return data; } if (k.startsWith('config.')) { data.config[k.substring(7)] = v; return data; } data[k] = v; return data; }, {}); // If targets are not specified, don't send alert rule config to backend if (!result.alertRule?.targets) { result.alertRule = undefined; } return result; } /** * Transform config field errors from the error response */ function mapFormErrors(responseJson?: any) { if (responseJson.config === undefined) { return responseJson; } // Bring nested config entries to the top const {config, ...responseRest} = responseJson; const configErrors = Object.fromEntries( Object.entries(config).map(([key, value]) => [`config.${key}`, value]) ); return {...responseRest, ...configErrors}; } function MonitorForm({ monitor, submitLabel, apiEndpoint, apiMethod, onSubmitSuccess, }: Props) { const form = useRef(new FormModel({transformData, mapFormErrors})); const {projects} = useProjects(); const {selection} = usePageFilters(); function formDataFromConfig(type: MonitorType, config: MonitorConfig) { const rv = {}; switch (type) { case 'cron_job': rv['config.schedule_type'] = config.schedule_type; rv['config.checkin_margin'] = config.checkin_margin; rv['config.max_runtime'] = config.max_runtime; switch (config.schedule_type) { case 'interval': rv['config.schedule.frequency'] = config.schedule[0]; rv['config.schedule.interval'] = config.schedule[1]; break; case 'crontab': default: rv['config.schedule'] = config.schedule; rv['config.timezone'] = config.timezone; } break; default: } return rv; } const selectedProjectId = selection.projects[0]; const selectedProject = selectedProjectId ? projects.find(p => p.id === selectedProjectId + '') : null; const isSuperuser = isActiveSuperuser(); const filteredProjects = projects.filter(project => isSuperuser || project.isMember); const alertRuleTarget = monitor?.alertRule?.targets.map( target => `${RULES_SELECTOR_MAP[target.targetType]}:${target.targetIdentifier}` ); const envOptions = selectedProject?.environments.map(e => ({value: e, label: e})) ?? []; const alertRuleEnvs = [ { label: 'All Environments', value: '', }, ...envOptions, ]; return (
{t('Add a name and project')} {t('The monitor name will show up in alerts and notifications')} {monitor && ( } )} placeholder={t('monitor-slug')} required stacked inline={false} transformInput={slugify} /> )} {t('Choose your schedule type')} {tct('You can use [link:the crontab syntax] or our interval schedule.', { link: , })} {t('Choose your schedule')} {t('How often you expect your recurring jobs to run.')} {monitor !== undefined && ( {t( 'Any changes you make to the execution schedule will only be applied after the next expected check-in.' )} )} {() => { const scheduleType = form.current.getValue('config.schedule_type'); const parsedSchedule = scheduleType === 'crontab' ? crontabAsText( form.current.getValue('config.schedule')?.toString() ?? '' ) : null; if (scheduleType === 'crontab') { return ( {parsedSchedule && "{parsedSchedule}"} ); } if (scheduleType === 'interval') { return ( {t('Every')} ); } return null; }} {t('Set a missed status')} {t("The number of minutes we'll wait before we consider a check-in as missed.")} {t('Set a failed status')} {t( "The number of minutes a check-in is allowed to run before it's considered failed." )} {t('Notify members')} {t( 'Tell us who to notify when a check-in reaches the thresholds above or has an error. You can send notifications to members or teams.' )} {monitor?.config.alert_rule_id && ( {t('Customize this monitors notification configuration in Alerts')} )}
); } export default MonitorForm; const StyledList = styled(List)` width: 600px; `; const StyledNumberField = styled(NumberField)` padding: 0; `; const StyledSelectField = styled(SelectField)` padding: 0; `; const StyledTextField = styled(TextField)` padding: 0; `; const StyledSentryProjectSelectorField = styled(SentryProjectSelectorField)` padding: 0; `; const StyledSentryMemberTeamSelectorField = styled(SentryMemberTeamSelectorField)` padding: 0; `; const StyledListItem = styled(ListItem)` font-size: ${p => p.theme.fontSizeExtraLarge}; font-weight: bold; line-height: 1.3; `; const LabelText = styled(Text)` font-weight: bold; color: ${p => p.theme.subText}; `; const ListItemSubText = styled(LabelText)` font-weight: normal; padding-left: ${space(4)}; `; const InputGroup = styled('div')` padding-left: ${space(4)}; margin-top: ${space(1)}; margin-bottom: ${space(4)}; display: flex; flex-direction: column; gap: ${space(1)}; `; const MultiColumnInput = styled('div')<{columns?: string}>` display: grid; align-items: center; gap: ${space(1)}; grid-template-columns: ${p => p.columns}; `; const CronstrueText = styled(LabelText)` font-weight: normal; font-size: ${p => p.theme.fontSizeExtraSmall}; font-family: ${p => p.theme.text.familyMono}; grid-column: auto / span 2; `;