Skip to content

Commit 463e80f

Browse files
Improve UI per metric type on alert create form (#8150)
1 parent 3390c64 commit 463e80f

9 files changed

Lines changed: 506 additions & 76 deletions

File tree

packages/web/app/src/components/base/form/form.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const Default: Story = () => {
4040
<FormItem>
4141
<FormLabel label="Alert name" />
4242
<FormControl>
43-
<Input placeholder="Some cool alert name" {...field} />
43+
<Input placeholder="Enter alert name" {...field} />
4444
</FormControl>
4545
<FormMessage />
4646
</FormItem>
@@ -84,7 +84,7 @@ export const WithErrors: Story = () => {
8484
<FormItem>
8585
<FormLabel label="Alert name" />
8686
<FormControl>
87-
<Input placeholder="Some cool alert name" {...field} />
87+
<Input placeholder="Enter alert name" {...field} />
8888
</FormControl>
8989
<FormMessage />
9090
</FormItem>

packages/web/app/src/components/base/input/input.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default {
77

88
export const Default: Story = () => (
99
<div className="max-w-sm p-8">
10-
<Input placeholder="Some cool alert name" />
10+
<Input placeholder="Enter alert name" />
1111
</div>
1212
);
1313

packages/web/app/src/components/target/alerts/alert-conditions-panel.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,22 @@ export function AlertConditionsPanel({
170170
? `${rule.metric.toLowerCase()} latency`
171171
: METRIC_LABEL_BY_TYPE[rule.type];
172172

173-
// A PERCENTAGE_CHANGE threshold is always a percent. A fixed value carries the
174-
// metric's own unit: ms for latency (rendered via formatDuration, e.g.
175-
// "4.00s"), percent for error rate, and a bare count for traffic.
176173
const thresholdDisplay =
177174
rule.thresholdType === MetricAlertRuleThresholdType.PercentageChange
178-
? `${rule.thresholdValue}%`
175+
? `${Math.abs(rule.thresholdValue)}%`
179176
: rule.type === MetricAlertRuleType.Latency
180177
? formatDuration(rule.thresholdValue, true)
181178
: rule.type === MetricAlertRuleType.ErrorRate
182179
? `${rule.thresholdValue}%`
183180
: String(rule.thresholdValue);
184181

182+
const conditionLabel =
183+
rule.thresholdType === MetricAlertRuleThresholdType.PercentageChange
184+
? rule.direction.toUpperCase() === 'BELOW'
185+
? 'decrease'
186+
: 'increase'
187+
: rule.direction.toLowerCase();
188+
185189
const destination = destinationLabel(rule.channels);
186190

187191
const onFilterValue = rule.savedFilter ? (
@@ -239,7 +243,7 @@ export function AlertConditionsPanel({
239243
items: [
240244
{
241245
term: 'Condition',
242-
description: <span className="capitalize">{rule.direction.toLowerCase()}</span>,
246+
description: <span className="capitalize">{conditionLabel}</span>,
243247
},
244248
{
245249
term: 'Threshold type',

packages/web/app/src/components/target/alerts/alert-form.tsx

Lines changed: 104 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
4444
import { Link } from '@tanstack/react-router';
4545
import { AlertMetricChart } from './alert-metric-chart';
4646
import { AlertPreview } from './alert-notification-preview';
47+
import { applyThresholdSign, thresholdUnit } from './alert-threshold';
4748

4849
const AlertForm_ChannelsQuery = graphql(`
4950
query AlertForm_ChannelsQuery($organizationSlug: String!, $projectSlug: String!) {
@@ -227,11 +228,21 @@ const RANGE_OPTIONS = [
227228
{ value: '43200', label: '30d' },
228229
] as const;
229230

230-
const CONDITION_OPTIONS = [
231+
// Condition labels depend on threshold type. For a fixed value the metric is
232+
// compared against an absolute level ("Above"/"Below"). For a window-over-window
233+
// % change the rule fires on a rise or a fall, so "Increase"/"Decrease" reads
234+
// naturally and lets the user enter a positive magnitude (the sign is applied
235+
// for them in `applyThresholdSign`). The underlying values stay ABOVE/BELOW.
236+
const CONDITION_OPTIONS_FIXED = [
231237
{ value: 'ABOVE', label: 'Above' },
232238
{ value: 'BELOW', label: 'Below' },
233239
] as const;
234240

241+
const CONDITION_OPTIONS_CHANGE = [
242+
{ value: 'ABOVE', label: 'Increase' },
243+
{ value: 'BELOW', label: 'Decrease' },
244+
] as const;
245+
235246
const THRESHOLD_TYPE_OPTIONS = [
236247
{ value: 'FIXED_VALUE', label: 'Fixed value' },
237248
// "% change vs. previous" names the behavior (window-over-window comparison)
@@ -247,26 +258,51 @@ const SEVERITIES = [
247258
{ value: 'CRITICAL' as const, label: 'Critical', dotClass: 'bg-red-400' },
248259
];
249260

250-
export const AlertFormSchema = z.object({
251-
metricSelection: z.string().min(1, 'Metric is required'),
252-
timeWindowMinutes: z.string().min(1, 'Range is required'),
253-
name: z.string().min(1, 'Name is required'),
254-
severity: z.enum(['INFO', 'WARNING', 'CRITICAL']),
255-
direction: z.string().min(1),
256-
thresholdType: z.string().min(1),
257-
thresholdValue: z.string().min(1, 'Value is required'),
258-
savedFilterId: z.string().optional(),
259-
confirmationMinutes: z.string().default('0'),
260-
// Zero channels is intentionally allowed here so users can create a rule
261-
// and observe its state transitions in the UI without firing notifications
262-
// ("test mode"), then attach destinations once the rule's behavior is
263-
// trusted.
264-
channels: z.array(
265-
z.object({
266-
channelId: z.string().min(1, 'Select a channel'),
267-
}),
268-
),
269-
});
261+
export const AlertFormSchema = z
262+
.object({
263+
metricSelection: z.string().min(1, 'Metric is required'),
264+
timeWindowMinutes: z.string().min(1, 'Range is required'),
265+
name: z.string().min(1, 'Name is required'),
266+
severity: z.enum(['INFO', 'WARNING', 'CRITICAL']),
267+
direction: z.string().min(1),
268+
thresholdType: z.string().min(1),
269+
thresholdValue: z.string().min(1, 'Value is required'),
270+
savedFilterId: z.string().optional(),
271+
confirmationMinutes: z.string().default('0'),
272+
// Zero channels is intentionally allowed here so users can create a rule
273+
// and observe its state transitions in the UI without firing notifications
274+
// ("test mode"), then attach destinations once the rule's behavior is
275+
// trusted.
276+
channels: z.array(
277+
z.object({
278+
channelId: z.string().min(1, 'Select a channel'),
279+
}),
280+
),
281+
})
282+
.superRefine((data, ctx) => {
283+
const value = parseFloat(data.thresholdValue);
284+
if (Number.isNaN(value)) {
285+
return; // empty is already caught by `.min(1)`; non-numeric by the input
286+
}
287+
if (value < 0) {
288+
ctx.addIssue({
289+
code: z.ZodIssueCode.custom,
290+
path: ['thresholdValue'],
291+
message: 'Value must be 0 or greater',
292+
});
293+
}
294+
const { type } = parseMetricSelection(data.metricSelection);
295+
const capAt100 =
296+
(data.thresholdType === 'FIXED_VALUE' && type === MetricAlertRuleType.ErrorRate) ||
297+
(data.thresholdType === 'PERCENTAGE_CHANGE' && data.direction === 'BELOW');
298+
if (capAt100 && value > 100) {
299+
ctx.addIssue({
300+
code: z.ZodIssueCode.custom,
301+
path: ['thresholdValue'],
302+
message: 'Value cannot exceed 100%',
303+
});
304+
}
305+
});
270306

271307
export type AlertFormValues = z.infer<typeof AlertFormSchema>;
272308

@@ -359,7 +395,7 @@ export function ruleToFormDefaults(rule: AlertFormRuleSeed): AlertFormValues {
359395
severity: severityKey,
360396
direction: String(rule.direction).toUpperCase(),
361397
thresholdType: String(rule.thresholdType).toUpperCase(),
362-
thresholdValue: String(rule.thresholdValue),
398+
thresholdValue: String(Math.abs(rule.thresholdValue)),
363399
savedFilterId: rule.savedFilter?.id ?? '',
364400
confirmationMinutes: String(rule.confirmationMinutes),
365401
channels: rule.channels.map(c => ({ channelId: c.id })),
@@ -433,7 +469,11 @@ export function AlertForm(props: AlertFormProps) {
433469
metric: metric ?? null,
434470
timeWindowMinutes: parseInt(values.timeWindowMinutes, 10),
435471
thresholdType: THRESHOLD_TYPE_MAP[values.thresholdType],
436-
thresholdValue: parseFloat(values.thresholdValue),
472+
thresholdValue: applyThresholdSign(
473+
parseFloat(values.thresholdValue),
474+
values.thresholdType,
475+
values.direction,
476+
),
437477
direction: DIRECTION_MAP[values.direction],
438478
severity: SEVERITY_MAP[values.severity],
439479
confirmationMinutes: parseInt(values.confirmationMinutes || '0', 10),
@@ -465,7 +505,11 @@ export function AlertForm(props: AlertFormProps) {
465505
metric: metric ?? null,
466506
timeWindowMinutes: parseInt(values.timeWindowMinutes, 10),
467507
thresholdType: THRESHOLD_TYPE_MAP[values.thresholdType],
468-
thresholdValue: parseFloat(values.thresholdValue),
508+
thresholdValue: applyThresholdSign(
509+
parseFloat(values.thresholdValue),
510+
values.thresholdType,
511+
values.direction,
512+
),
469513
direction: DIRECTION_MAP[values.direction],
470514
severity: SEVERITY_MAP[values.severity],
471515
confirmationMinutes: parseInt(values.confirmationMinutes || '0', 10),
@@ -521,6 +565,22 @@ export function AlertForm(props: AlertFormProps) {
521565
? parseMetricSelection(watchedValues.metricSelection)
522566
: { type: MetricAlertRuleType.Traffic };
523567

568+
const isPercentageChange = watchedValues.thresholdType === 'PERCENTAGE_CHANGE';
569+
const valueUnit = thresholdUnit(parsedMetric.type, watchedValues.thresholdType);
570+
const conditionOptions = isPercentageChange ? CONDITION_OPTIONS_CHANGE : CONDITION_OPTIONS_FIXED;
571+
const valueMax =
572+
(!isPercentageChange && parsedMetric.type === MetricAlertRuleType.ErrorRate) ||
573+
(isPercentageChange && watchedValues.direction === 'BELOW')
574+
? 100
575+
: undefined;
576+
const valuePlaceholder = isPercentageChange
577+
? 'e.g. 25'
578+
: parsedMetric.type === MetricAlertRuleType.Latency
579+
? 'e.g. 500'
580+
: parsedMetric.type === MetricAlertRuleType.ErrorRate
581+
? 'e.g. 5'
582+
: 'e.g. 1000';
583+
524584
const previewWindowMinutes = Math.min(
525585
(parseInt(watchedValues.timeWindowMinutes, 10) || 10_080) * 2,
526586
43_200,
@@ -685,7 +745,7 @@ export function AlertForm(props: AlertFormProps) {
685745
<FormItem>
686746
<FormLabel label="Alert name" />
687747
<FormControl>
688-
<Input placeholder="Some cool alert name" {...field} />
748+
<Input placeholder="Enter alert name" {...field} />
689749
</FormControl>
690750
<FormMessage />
691751
</FormItem>
@@ -735,7 +795,7 @@ export function AlertForm(props: AlertFormProps) {
735795
<FormLabel label="Condition" />
736796
<FormControl>
737797
<Select
738-
options={CONDITION_OPTIONS}
798+
options={conditionOptions}
739799
value={field.value}
740800
onValueChange={field.onChange}
741801
/>
@@ -766,18 +826,31 @@ export function AlertForm(props: AlertFormProps) {
766826
name="thresholdValue"
767827
render={({ field }) => (
768828
<FormItem>
769-
<FormLabel label="Value" />
829+
<FormLabel label={`Value (${valueUnit})`} />
770830
<FormControl>
771-
<Input type="number" placeholder="Enter a value" {...field} />
831+
<Input
832+
type="number"
833+
min={0}
834+
max={valueMax}
835+
placeholder={valuePlaceholder}
836+
style={{ minWidth: '7rem' }}
837+
{...field}
838+
/>
772839
</FormControl>
773840
<FormMessage />
774841
</FormItem>
775842
)}
776843
/>
777844
</div>
778845
<p className="text-neutral-10 text-[13px]">
779-
{watchedValues.thresholdType === 'PERCENTAGE_CHANGE'
780-
? `"% change vs. previous" compares this ${thresholdRangeLabel} window to the one before it. Your value is the percent change between them, e.g. 75 fires on a +75% change rather than an absolute level.`
846+
{isPercentageChange
847+
? `"% change vs. previous" compares this ${thresholdRangeLabel} window to the one before it. With "${
848+
watchedValues.direction === 'BELOW' ? 'a Decrease' : 'an Increase'
849+
}" it fires when the metric ${
850+
watchedValues.direction === 'BELOW' ? 'drops' : 'rises'
851+
} by more than your value, e.g. 75 fires on a ${
852+
watchedValues.direction === 'BELOW' ? '−75%' : '+75%'
853+
} change rather than an absolute level.`
781854
: `"Fixed value" compares the metric over the ${thresholdRangeLabel} window directly against your value, in the metric's own unit (% for error rate, ms for latency, requests for total requests).`}
782855
</p>
783856
</div>
@@ -793,6 +866,7 @@ export function AlertForm(props: AlertFormProps) {
793866
}
794867
direction={watchedValues.direction}
795868
thresholdType={watchedValues.thresholdType}
869+
timeWindowMinutes={parseInt(watchedValues.timeWindowMinutes, 10) || 0}
796870
/>
797871

798872
<Accordion defaultValue={expandAdvanced ? [0] : undefined}>

0 commit comments

Comments
 (0)