@@ -44,6 +44,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
4444import { Link } from '@tanstack/react-router' ;
4545import { AlertMetricChart } from './alert-metric-chart' ;
4646import { AlertPreview } from './alert-notification-preview' ;
47+ import { applyThresholdSign , thresholdUnit } from './alert-threshold' ;
4748
4849const 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+
235246const 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
271307export 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