Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions static/app/views/detectors/components/forms/uptime/detect.tsx

This file was deleted.

153 changes: 153 additions & 0 deletions static/app/views/detectors/components/forms/uptime/detect/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import styled from '@emotion/styled';

import {Alert} from 'sentry/components/core/alert';
import {FieldWrapper} from 'sentry/components/forms/fieldGroup/fieldWrapper';
import BooleanField from 'sentry/components/forms/fields/booleanField';
import RangeField from 'sentry/components/forms/fields/rangeField';
import SelectField from 'sentry/components/forms/fields/selectField';
import TextareaField from 'sentry/components/forms/fields/textareaField';
import TextField from 'sentry/components/forms/fields/textField';
import type FormModel from 'sentry/components/forms/model';
import ExternalLink from 'sentry/components/links/externalLink';
import {Container} from 'sentry/components/workflowEngine/ui/container';
import Section from 'sentry/components/workflowEngine/ui/section';
import {t, tct} from 'sentry/locale';
import getDuration from 'sentry/utils/duration/getDuration';
import {UptimeHeadersField} from 'sentry/views/detectors/components/forms/uptime/detect/uptimeHeadersField';

const HTTP_METHOD_OPTIONS = ['GET', 'POST', 'HEAD', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
const HTTP_METHODS_NO_BODY = ['GET', 'HEAD', 'OPTIONS'];
const MINUTE = 60;
const VALID_INTERVALS_SEC = [
MINUTE * 1,
MINUTE * 5,
MINUTE * 10,
MINUTE * 20,
MINUTE * 30,
MINUTE * 60,
];

function methodHasBody(model: FormModel) {
return !HTTP_METHODS_NO_BODY.includes(model.getValue('method'));
}

export function UptimeDetectorFormDetectSection() {
return (
<Container>
<Section title={t('Detect')}>
<DetectFieldsContainer>
<div>
<SelectField
options={VALID_INTERVALS_SEC.map(value => ({
value,
label: t('Every %s', getDuration(value)),
}))}
name="intervalSeconds"
label={t('Interval')}
defaultValue={60}
flexibleControlStateSize
showHelpInTooltip={{isHoverable: true}}
help={({model}) =>
tct(
'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].',
{
link: (
<ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/#uptime-check-failures" />
),
interval: (
<strong>{getDuration(model.getValue('intervalSeconds'))}</strong>
),
expectedFailureInterval: (
<strong>
{getDuration(Number(model.getValue('intervalSeconds')) * 3)}
</strong>
),
}
)
}
required
/>
<RangeField
name="timeoutMs"
label={t('Timeout')}
min={1000}
max={60_000}
step={250}
tickValues={[1_000, 10_000, 20_000, 30_000, 40_000, 50_000, 60_000]}
defaultValue={5_000}
showTickLabels
formatLabel={value => getDuration((value || 0) / 1000, 2, true)}
flexibleControlStateSize
required
/>
<TextField
name="url"
label={t('URL')}
placeholder={t('The URL to monitor')}
flexibleControlStateSize
monospace
required
/>
<SelectField
name="method"
label={t('Method')}
defaultValue="GET"
options={HTTP_METHOD_OPTIONS.map(option => ({
value: option,
label: option,
}))}
flexibleControlStateSize
required
/>
<UptimeHeadersField
name="headers"
label={t('Headers')}
flexibleControlStateSize
/>
<TextareaField
name="body"
label={t('Body')}
visible={({model}: any) => methodHasBody(model)}
rows={4}
maxRows={15}
autosize
monospace
placeholder='{"key": "value"}'
flexibleControlStateSize
/>
<BooleanField
name="traceSampling"
label={t('Allow Sampling')}
showHelpInTooltip={{isHoverable: true}}
help={tct(
'Defer the sampling decision to a Sentry SDK configured in your application. Disable to prevent all span sampling. [link:Learn more].',
{
link: (
<ExternalLink href="https://docs.sentry.io/product/alerts/uptime-monitoring/uptime-tracing/" />
),
}
)}
flexibleControlStateSize
/>
</div>
<Alert type="muted" showIcon>
{tct(
'By enabling uptime monitoring, you acknowledge that uptime check data may be stored outside your selected data region. [link:Learn more].',
{
link: (
<ExternalLink href="https://docs.sentry.io/organization/data-storage-location/#data-stored-in-us" />
),
}
)}
</Alert>
</DetectFieldsContainer>
</Section>
</Container>
);
}

const DetectFieldsContainer = styled('div')`
${FieldWrapper} {
padding-left: 0;
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {useEffect, useState} from 'react';
import styled from '@emotion/styled';

import {Button} from 'sentry/components/core/button';
import {Input} from 'sentry/components/core/input';
import type {FormFieldProps} from 'sentry/components/forms/formField';
import FormField from 'sentry/components/forms/formField';
import FormFieldControlState from 'sentry/components/forms/formField/controlState';
import {IconAdd, IconDelete} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {uniqueId} from 'sentry/utils/guid';

/**
* Matches characters that are not valid in a header name.
*/
const INVALID_NAME_HEADER_REGEX = new RegExp(/[^a-zA-Z0-9_-]+/g);

type HeaderEntry = [id: string, name: string, value: string];

// XXX(epurkhiser): The types of the FormField render props are absolutely
// abysmal, so we're leaving this untyped for now.

function UptimHeadersControl(props: any) {
const {onChange, onBlur, disabled, model, name, value} = props;

// Store itmes in local state so we can add empty values without persisting
// those into the form model.
const [items, setItems] = useState<HeaderEntry[]>(() =>
Object.keys(value).length > 0
? value.map((v: any) => [uniqueId(), ...v] as HeaderEntry)
: [[uniqueId(), '', '']]
);

// Persist the field value back to the form model on changes to the items
// list. Empty items are discarded and not persisted.
useEffect(() => {
const newValue = items.filter(item => item[1] !== '').map(item => [item[1], item[2]]);

onChange(newValue, {});
onBlur(newValue, {});
}, [items, onChange, onBlur]);

function addItem() {
setItems(currentItems => [...currentItems, [uniqueId(), '', '']]);
}

function removeItem(index: number) {
setItems(currentItems => currentItems.toSpliced(index, 1));
}

function handleNameChange(index: number, newName: string) {
setItems(currentItems =>
currentItems.toSpliced(index, 1, [
items[index]![0],
newName.replaceAll(INVALID_NAME_HEADER_REGEX, ''),
items[index]![2],
])
);
}

function handleValueChange(index: number, newHeaderValue: string) {
setItems(currentItems =>
currentItems.toSpliced(index, 1, [
items[index]![0],
items[index]![1],
newHeaderValue,
])
);
}

/**
* Disambiguates headers that are named the same by adding a `(x)` number to
* the end of the name in the order they were added.
*/
function disambiguateHeaderName(index: number) {
const headerName = items[index]![1];
const matchingIndexes = items
.map((item, idx) => [idx, item[1]])
.filter(([_, itemName]) => itemName === headerName)
.map(([idx]) => idx);

const duplicateIndex = matchingIndexes.indexOf(index) + 1;

return duplicateIndex === 1 ? headerName : `${headerName} (${duplicateIndex})`;
}

return (
<HeadersContainer>
{items.length > 0 && (
<HeaderItems>
{items.map(([id, headerName, headerValue], index) => (
<HeaderRow key={id}>
<Input
monospace
disabled={disabled}
value={headerName ?? ''}
placeholder="X-Header-Value"
onChange={e => handleNameChange(index, e.target.value)}
aria-label={t('Name of header %s', index + 1)}
/>
<Input
monospace
disabled={disabled}
value={headerValue ?? ''}
placeholder={t('Header Value')}
onChange={e => handleValueChange(index, e.target.value)}
aria-label={
headerName
? t('Value of %s', disambiguateHeaderName(index))
: t('Value of header %s', index + 1)
}
/>
<Button
disabled={disabled}
icon={<IconDelete />}
size="sm"
borderless
aria-label={
headerName
? t('Remove %s', disambiguateHeaderName(index))
: t('Remove header %s', index + 1)
}
onClick={() => removeItem(index)}
/>
</HeaderRow>
))}
</HeaderItems>
)}
<HeaderActions>
<Button disabled={disabled} icon={<IconAdd />} size="sm" onClick={addItem}>
{t('Add Header')}
</Button>
<FormFieldControlState model={model} name={name} />
</HeaderActions>
</HeadersContainer>
);
}

export function UptimeHeadersField(props: Omit<FormFieldProps, 'children'>) {
return (
<FormField defaultValue={[]} {...props} hideControlState flexibleControlStateSize>
{({ref: _ref, ...fieldProps}) => <UptimHeadersControl {...fieldProps} />}
</FormField>
);
}

const HeadersContainer = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(1)};
`;

const HeaderActions = styled('div')`
display: flex;
gap: ${space(1.5)};
`;

const HeaderItems = styled('fieldset')`
display: grid;
grid-template-columns: minmax(200px, 1fr) 2fr max-content;
gap: ${space(1)};
width: 100%;
`;

const HeaderRow = styled('div')`
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center;
`;
42 changes: 42 additions & 0 deletions static/app/views/detectors/components/forms/uptime/fields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {useFormField} from 'sentry/components/workflowEngine/form/useFormField';

interface UptimeDetectorFormData {
environment: string;
intervalSeconds: number;
method: string;
name: string;
owner: string;
projectId: string;
timeoutMs: number;
traceSampling: boolean;
url: string;
}

type UptimeDetectorFormFieldName = keyof UptimeDetectorFormData;

/**
* Small helper to automatically get the type of the form field.
*/
export function useUptimeDetectorFormField<T extends UptimeDetectorFormFieldName>(
name: T
): UptimeDetectorFormData[T] {
const value = useFormField(name);
return value;
}

/**
* Enables type-safe form field names.
* Helps you find areas setting specific fields.
*/
export const UPTIME_DETECTOR_FORM_FIELDS = {
// Core detector fields
name: 'name',
environment: 'environment',
projectId: 'projectId',
owner: 'owner',
intervalSeconds: 'intervalSeconds',
timeoutMs: 'timeoutMs',
url: 'url',
method: 'method',
traceSampling: 'traceSampling',
} satisfies Record<UptimeDetectorFormFieldName, UptimeDetectorFormFieldName>;
Loading
Loading