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 FieldWrapper from 'sentry/components/forms/fieldGroup/fieldWrapper'; import NumberField from 'sentry/components/forms/fields/numberField'; 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 type {FormProps} from 'sentry/components/forms/form'; import Form 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 Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; 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 type {SelectValue} from 'sentry/types/core'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import slugify from 'sentry/utils/slugify'; import commonTheme from 'sentry/utils/theme'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {getScheduleIntervals} from 'sentry/views/monitors/utils'; import {crontabAsText} from 'sentry/views/monitors/utils/crontabAsText'; import type {IntervalConfig, Monitor, MonitorConfig, MonitorType} from '../types'; import {ScheduleType} from '../types'; import {platformsWithGuides} from './monitorQuickStartGuide'; const SCHEDULE_OPTIONS: SelectValue[] = [ {value: ScheduleType.CRONTAB, label: t('Crontab')}, {value: ScheduleType.INTERVAL, label: t('Interval')}, ]; export const DEFAULT_MONITOR_TYPE = 'cron_job'; export 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', user: 'Member'} as const; const RULES_SELECTOR_MAP = {Team: 'team', Member: 'user'} as const; // In minutes export const DEFAULT_MAX_RUNTIME = 30; export const DEFAULT_CHECKIN_MARGIN = 1; const CHECKIN_MARGIN_MINIMUM = 1; const TIMEOUT_MINIMUM = 1; 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 */ export function transformMonitorFormData(_data: Record, model: FormModel) { const schedType = model.getValue('config.scheduleType'); // Remove interval fields if the monitor schedule is crontab const filteredFields = model.fields .toJSON() .filter( ([k, _v]) => (schedType === ScheduleType.CRONTAB && k !== 'config.schedule.interval' && k !== 'config.schedule.frequency') || schedType === ScheduleType.INTERVAL ); const result = filteredFields.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 */ export function mapMonitorFormErrors(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: transformMonitorFormData, mapFormErrors: mapMonitorFormErrors, }) ); const {projects} = useProjects(); const {selection} = usePageFilters(); function formDataFromConfig(type: MonitorType, config: MonitorConfig) { const rv = {}; switch (type) { case 'cron_job': rv['config.scheduleType'] = config.schedule_type; rv['config.checkinMargin'] = config.checkin_margin; rv['config.maxRuntime'] = config.max_runtime; rv['config.failureIssueThreshold'] = config.failure_issue_threshold; rv['config.recoveryThreshold'] = config.recovery_threshold; 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 = monitor?.project.id ?? selection.projects[0]; const selectedProject = selectedProjectId ? projects.find(p => p.id === selectedProjectId.toString()) : 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 owner = monitor?.owner ? `${monitor.owner.type}:${monitor.owner.id}` : null; 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 name will show up in notifications.')} {monitor && ( } )} placeholder={t('monitor-slug')} required stacked inline={false} transformInput={slugify} /> )} platformsWithGuides.includes(project.platform) ? 'suggested' : 'other' } groups={[ {key: 'suggested', label: t('Suggested Projects')}, {key: 'other', label: t('Other Projects')}, ]} projects={filteredProjects} placeholder={t('Choose Project')} disabled={!!monitor} disabledReason={t('Existing monitors cannot be moved between projects')} valueIsSlug required stacked inline={false} /> {t('Set your schedule')} {tct('You can use [link:the crontab syntax] or our interval schedule.', { link: , })} {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.scheduleType'); 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 margins')} {t('Configure when we mark your monitor as failed or missed.')} {t('Set thresholds')} {t('Configure when an issue is created or resolved.')} {t('Set Owner')} {t( 'Choose a team or member as the monitor owner. Issues created will be automatically assigned to the owner.' )} {t('Notifications')} {t('Configure who to notify upon issue creation and when.')} {monitor?.config.alert_rule_id && ( {t('Customize this monitors notification configuration in Alerts')} )} {() => { const projectSlug = form.current.getValue('project')?.toString(); return ( ); }} {() => { const selectedAssignee = form.current.getValue('alertRule.targets'); // Check for falsey value or empty array value const disabled = !selectedAssignee || !selectedAssignee.toString(); return ( ); }}
); } export default MonitorForm; const StyledList = styled(List)` width: 800px; `; const StyledAlert = styled(Alert)` margin-bottom: 0; `; const StyledListItem = styled(ListItem)` font-size: ${p => p.theme.fontSizeExtraLarge}; font-weight: ${p => p.theme.fontWeightBold}; line-height: 1.3; `; const LabelText = styled(Text)` font-weight: ${p => p.theme.fontWeightBold}; color: ${p => p.theme.subText}; `; const ListItemSubText = styled(Text)` padding-left: ${space(4)}; color: ${p => p.theme.subText}; `; const InputGroup = styled('div')<{noPadding?: boolean}>` padding-left: ${space(4)}; margin-top: ${space(1)}; margin-bottom: ${space(4)}; display: flex; flex-direction: column; gap: ${space(1)}; ${FieldWrapper} { ${p => p.noPadding && `padding: 0;`}; } `; 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: ${p => p.theme.fontWeightNormal}; font-size: ${p => p.theme.fontSizeExtraSmall}; font-family: ${p => p.theme.text.familyMono}; grid-column: auto / span 2; `;