Skip to content

Commit 90d3d07

Browse files
authored
feat(aci): Add uptime detector form (#94617)
1 parent 0f00efd commit 90d3d07

File tree

5 files changed

+372
-16
lines changed

5 files changed

+372
-16
lines changed

static/app/views/detectors/components/forms/uptime/detect.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Alert} from 'sentry/components/core/alert';
4+
import {FieldWrapper} from 'sentry/components/forms/fieldGroup/fieldWrapper';
5+
import BooleanField from 'sentry/components/forms/fields/booleanField';
6+
import RangeField from 'sentry/components/forms/fields/rangeField';
7+
import SelectField from 'sentry/components/forms/fields/selectField';
8+
import TextareaField from 'sentry/components/forms/fields/textareaField';
9+
import TextField from 'sentry/components/forms/fields/textField';
10+
import type FormModel from 'sentry/components/forms/model';
11+
import ExternalLink from 'sentry/components/links/externalLink';
12+
import {Container} from 'sentry/components/workflowEngine/ui/container';
13+
import Section from 'sentry/components/workflowEngine/ui/section';
14+
import {t, tct} from 'sentry/locale';
15+
import getDuration from 'sentry/utils/duration/getDuration';
16+
import {UptimeHeadersField} from 'sentry/views/detectors/components/forms/uptime/detect/uptimeHeadersField';
17+
18+
const HTTP_METHOD_OPTIONS = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
19+
const HTTP_METHODS_NO_BODY = ['GET', 'HEAD', 'OPTIONS'];
20+
const MINUTE = 60;
21+
const VALID_INTERVALS_SEC = [
22+
MINUTE * 1,
23+
MINUTE * 5,
24+
MINUTE * 10,
25+
MINUTE * 20,
26+
MINUTE * 30,
27+
MINUTE * 60,
28+
];
29+
30+
function methodHasBody(model: FormModel) {
31+
return !HTTP_METHODS_NO_BODY.includes(model.getValue('method'));
32+
}
33+
34+
export function UptimeDetectorFormDetectSection() {
35+
return (
36+
<Container>
37+
<Section title={t('Detect')}>
38+
<DetectFieldsContainer>
39+
<div>
40+
<SelectField
41+
options={VALID_INTERVALS_SEC.map(value => ({
42+
value,
43+
label: t('Every %s', getDuration(value)),
44+
}))}
45+
name="intervalSeconds"
46+
label={t('Interval')}
47+
defaultValue={60}
48+
flexibleControlStateSize
49+
showHelpInTooltip={{isHoverable: true}}
50+
help={({model}) =>
51+
tct(
52+
'The amount of time between each uptime check request. Selecting a period of [interval] means it will take at least [expectedFailureInterval] until you are notified of a failure. [link:Learn more].',
53+
{
54+
link: (
55+
<ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/#uptime-check-failures" />
56+
),
57+
interval: (
58+
<strong>{getDuration(model.getValue('intervalSeconds'))}</strong>
59+
),
60+
expectedFailureInterval: (
61+
<strong>
62+
{getDuration(Number(model.getValue('intervalSeconds')) * 3)}
63+
</strong>
64+
),
65+
}
66+
)
67+
}
68+
required
69+
/>
70+
<RangeField
71+
name="timeoutMs"
72+
label={t('Timeout')}
73+
min={1000}
74+
max={60_000}
75+
step={250}
76+
tickValues={[1_000, 10_000, 20_000, 30_000, 40_000, 50_000, 60_000]}
77+
defaultValue={5_000}
78+
showTickLabels
79+
formatLabel={value => getDuration((value || 0) / 1000, 2, true)}
80+
flexibleControlStateSize
81+
required
82+
/>
83+
<TextField
84+
name="url"
85+
label={t('URL')}
86+
placeholder={t('The URL to monitor')}
87+
flexibleControlStateSize
88+
monospace
89+
required
90+
/>
91+
<SelectField
92+
name="method"
93+
label={t('Method')}
94+
defaultValue="GET"
95+
options={HTTP_METHOD_OPTIONS.map(option => ({
96+
value: option,
97+
label: option,
98+
}))}
99+
flexibleControlStateSize
100+
required
101+
/>
102+
<UptimeHeadersField
103+
name="headers"
104+
label={t('Headers')}
105+
flexibleControlStateSize
106+
/>
107+
<TextareaField
108+
name="body"
109+
label={t('Body')}
110+
visible={({model}: any) => methodHasBody(model)}
111+
rows={4}
112+
maxRows={15}
113+
autosize
114+
monospace
115+
placeholder='{"key": "value"}'
116+
flexibleControlStateSize
117+
/>
118+
<BooleanField
119+
name="traceSampling"
120+
label={t('Allow Sampling')}
121+
showHelpInTooltip={{isHoverable: true}}
122+
help={tct(
123+
'Defer the sampling decision to a Sentry SDK configured in your application. Disable to prevent all span sampling. [link:Learn more].',
124+
{
125+
link: (
126+
<ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/uptime-tracing/" />
127+
),
128+
}
129+
)}
130+
flexibleControlStateSize
131+
/>
132+
</div>
133+
<Alert type="muted" showIcon>
134+
{tct(
135+
'By enabling uptime monitoring, you acknowledge that uptime check data may be stored outside your selected data region. [link:Learn more].',
136+
{
137+
link: (
138+
<ExternalLink href="https://docs.sentry.io/organization/data-storage-location/#data-stored-in-us" />
139+
),
140+
}
141+
)}
142+
</Alert>
143+
</DetectFieldsContainer>
144+
</Section>
145+
</Container>
146+
);
147+
}
148+
149+
const DetectFieldsContainer = styled('div')`
150+
${FieldWrapper} {
151+
padding-left: 0;
152+
}
153+
`;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import {useEffect, useState} from 'react';
2+
import styled from '@emotion/styled';
3+
4+
import {Button} from 'sentry/components/core/button';
5+
import {Input} from 'sentry/components/core/input';
6+
import type {FormFieldProps} from 'sentry/components/forms/formField';
7+
import FormField from 'sentry/components/forms/formField';
8+
import FormFieldControlState from 'sentry/components/forms/formField/controlState';
9+
import {IconAdd, IconDelete} from 'sentry/icons';
10+
import {t} from 'sentry/locale';
11+
import {space} from 'sentry/styles/space';
12+
import {uniqueId} from 'sentry/utils/guid';
13+
14+
/**
15+
* Matches characters that are not valid in a header name.
16+
*/
17+
const INVALID_NAME_HEADER_REGEX = new RegExp(/[^a-zA-Z0-9_-]+/g);
18+
19+
type HeaderEntry = [id: string, name: string, value: string];
20+
21+
// XXX(epurkhiser): The types of the FormField render props are absolutely
22+
// abysmal, so we're leaving this untyped for now.
23+
24+
function UptimHeadersControl(props: any) {
25+
const {onChange, onBlur, disabled, model, name, value} = props;
26+
27+
// Store itmes in local state so we can add empty values without persisting
28+
// those into the form model.
29+
const [items, setItems] = useState<HeaderEntry[]>(() =>
30+
Object.keys(value).length > 0
31+
? value.map((v: any) => [uniqueId(), ...v] as HeaderEntry)
32+
: [[uniqueId(), '', '']]
33+
);
34+
35+
// Persist the field value back to the form model on changes to the items
36+
// list. Empty items are discarded and not persisted.
37+
useEffect(() => {
38+
const newValue = items.filter(item => item[1] !== '').map(item => [item[1], item[2]]);
39+
40+
onChange(newValue, {});
41+
onBlur(newValue, {});
42+
}, [items, onChange, onBlur]);
43+
44+
function addItem() {
45+
setItems(currentItems => [...currentItems, [uniqueId(), '', '']]);
46+
}
47+
48+
function removeItem(index: number) {
49+
setItems(currentItems => currentItems.toSpliced(index, 1));
50+
}
51+
52+
function handleNameChange(index: number, newName: string) {
53+
setItems(currentItems =>
54+
currentItems.toSpliced(index, 1, [
55+
items[index]![0],
56+
newName.replaceAll(INVALID_NAME_HEADER_REGEX, ''),
57+
items[index]![2],
58+
])
59+
);
60+
}
61+
62+
function handleValueChange(index: number, newHeaderValue: string) {
63+
setItems(currentItems =>
64+
currentItems.toSpliced(index, 1, [
65+
items[index]![0],
66+
items[index]![1],
67+
newHeaderValue,
68+
])
69+
);
70+
}
71+
72+
/**
73+
* Disambiguates headers that are named the same by adding a `(x)` number to
74+
* the end of the name in the order they were added.
75+
*/
76+
function disambiguateHeaderName(index: number) {
77+
const headerName = items[index]![1];
78+
const matchingIndexes = items
79+
.map((item, idx) => [idx, item[1]])
80+
.filter(([_, itemName]) => itemName === headerName)
81+
.map(([idx]) => idx);
82+
83+
const duplicateIndex = matchingIndexes.indexOf(index) + 1;
84+
85+
return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`;
86+
}
87+
88+
return (
89+
<HeadersContainer>
90+
{items.length > 0 && (
91+
<HeaderItems>
92+
{items.map(([id, headerName, headerValue], index) => (
93+
<HeaderRow key={id}>
94+
<Input
95+
monospace
96+
disabled={disabled}
97+
value={headerName ?? ''}
98+
placeholder="X-Header-Value"
99+
onChange={e => handleNameChange(index, e.target.value)}
100+
aria-label={t('Name of header %s', index + 1)}
101+
/>
102+
<Input
103+
monospace
104+
disabled={disabled}
105+
value={headerValue ?? ''}
106+
placeholder={t('Header Value')}
107+
onChange={e => handleValueChange(index, e.target.value)}
108+
aria-label={
109+
headerName
110+
? t('Value of %s', disambiguateHeaderName(index))
111+
: t('Value of header %s', index + 1)
112+
}
113+
/>
114+
<Button
115+
disabled={disabled}
116+
icon={<IconDelete />}
117+
size="sm"
118+
borderless
119+
aria-label={
120+
headerName
121+
? t('Remove %s', disambiguateHeaderName(index))
122+
: t('Remove header %s', index + 1)
123+
}
124+
onClick={() => removeItem(index)}
125+
/>
126+
</HeaderRow>
127+
))}
128+
</HeaderItems>
129+
)}
130+
<HeaderActions>
131+
<Button disabled={disabled} icon={<IconAdd />} size="sm" onClick={addItem}>
132+
{t('Add Header')}
133+
</Button>
134+
<FormFieldControlState model={model} name={name} />
135+
</HeaderActions>
136+
</HeadersContainer>
137+
);
138+
}
139+
140+
export function UptimeHeadersField(props: Omit<FormFieldProps, 'children'>) {
141+
return (
142+
<FormField defaultValue={[]} {...props} hideControlState flexibleControlStateSize>
143+
{({ref: _ref, ...fieldProps}) => <UptimHeadersControl {...fieldProps} />}
144+
</FormField>
145+
);
146+
}
147+
148+
const HeadersContainer = styled('div')`
149+
display: flex;
150+
flex-direction: column;
151+
gap: ${space(1)};
152+
`;
153+
154+
const HeaderActions = styled('div')`
155+
display: flex;
156+
gap: ${space(1.5)};
157+
`;
158+
159+
const HeaderItems = styled('fieldset')`
160+
display: grid;
161+
grid-template-columns: minmax(200px, 1fr) 2fr max-content;
162+
gap: ${space(1)};
163+
width: 100%;
164+
`;
165+
166+
const HeaderRow = styled('div')`
167+
display: grid;
168+
grid-template-columns: subgrid;
169+
grid-column: 1 / -1;
170+
align-items: center;
171+
`;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {useFormField} from 'sentry/components/workflowEngine/form/useFormField';
2+
3+
interface UptimeDetectorFormData {
4+
environment: string;
5+
intervalSeconds: number;
6+
method: string;
7+
name: string;
8+
owner: string;
9+
projectId: string;
10+
timeoutMs: number;
11+
traceSampling: boolean;
12+
url: string;
13+
}
14+
15+
type UptimeDetectorFormFieldName = keyof UptimeDetectorFormData;
16+
17+
/**
18+
* Small helper to automatically get the type of the form field.
19+
*/
20+
export function useUptimeDetectorFormField<T extends UptimeDetectorFormFieldName>(
21+
name: T
22+
): UptimeDetectorFormData[T] {
23+
const value = useFormField(name);
24+
return value;
25+
}
26+
27+
/**
28+
* Enables type-safe form field names.
29+
* Helps you find areas setting specific fields.
30+
*/
31+
export const UPTIME_DETECTOR_FORM_FIELDS = {
32+
// Core detector fields
33+
name: 'name',
34+
environment: 'environment',
35+
projectId: 'projectId',
36+
owner: 'owner',
37+
intervalSeconds: 'intervalSeconds',
38+
timeoutMs: 'timeoutMs',
39+
url: 'url',
40+
method: 'method',
41+
traceSampling: 'traceSampling',
42+
} satisfies Record<UptimeDetectorFormFieldName, UptimeDetectorFormFieldName>;

0 commit comments

Comments
 (0)