1import React, { FC, useState } from 'react'; 2import { css, cx } from '@emotion/css'; 3import { GrafanaTheme2 } from '@grafana/data'; 4import { 5 Button, 6 Field, 7 FieldArray, 8 Form, 9 HorizontalGroup, 10 IconButton, 11 Input, 12 InputControl, 13 MultiSelect, 14 Select, 15 Switch, 16 useStyles2, 17} from '@grafana/ui'; 18import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes'; 19import { 20 emptyArrayFieldMatcher, 21 mapMultiSelectValueToStrings, 22 mapSelectValueToString, 23 optionalPositiveInteger, 24 stringToSelectableValue, 25 stringsToSelectableValues, 26} from '../../utils/amroutes'; 27import { timeOptions } from '../../utils/time'; 28import { getFormStyles } from './formStyles'; 29import { matcherFieldOptions } from '../../utils/alertmanager'; 30 31export interface AmRoutesExpandedFormProps { 32 onCancel: () => void; 33 onSave: (data: FormAmRoute) => void; 34 receivers: AmRouteReceiver[]; 35 routes: FormAmRoute; 36} 37 38export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel, onSave, receivers, routes }) => { 39 const styles = useStyles2(getStyles); 40 const formStyles = useStyles2(getFormStyles); 41 const [overrideGrouping, setOverrideGrouping] = useState(routes.groupBy.length > 0); 42 const [overrideTimings, setOverrideTimings] = useState( 43 !!routes.groupWaitValue || !!routes.groupIntervalValue || !!routes.repeatIntervalValue 44 ); 45 const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy)); 46 47 return ( 48 <Form defaultValues={routes} onSubmit={onSave}> 49 {({ control, register, errors, setValue }) => ( 50 <> 51 {/* @ts-ignore-check: react-hook-form made me do this */} 52 <input type="hidden" {...register('id')} /> 53 {/* @ts-ignore-check: react-hook-form made me do this */} 54 <FieldArray name="object_matchers" control={control}> 55 {({ fields, append, remove }) => ( 56 <> 57 <div>Matching labels</div> 58 <div className={styles.matchersContainer}> 59 {fields.map((field, index) => { 60 const localPath = `object_matchers[${index}]`; 61 return ( 62 <HorizontalGroup key={field.id} align="flex-start"> 63 <Field 64 label="Label" 65 invalid={!!errors.object_matchers?.[index]?.name} 66 error={errors.object_matchers?.[index]?.name?.message} 67 > 68 <Input 69 {...register(`${localPath}.name`, { required: 'Field is required' })} 70 defaultValue={field.name} 71 placeholder="label" 72 /> 73 </Field> 74 <Field label={'Operator'}> 75 <InputControl 76 render={({ field: { onChange, ref, ...field } }) => ( 77 <Select 78 {...field} 79 className={styles.matchersOperator} 80 onChange={(value) => onChange(value?.value)} 81 options={matcherFieldOptions} 82 aria-label="Operator" 83 /> 84 )} 85 defaultValue={field.operator} 86 control={control} 87 name={`${localPath}.operator` as const} 88 rules={{ required: { value: true, message: 'Required.' } }} 89 /> 90 </Field> 91 <Field 92 label="Value" 93 invalid={!!errors.object_matchers?.[index]?.value} 94 error={errors.object_matchers?.[index]?.value?.message} 95 > 96 <Input 97 {...register(`${localPath}.value`, { required: 'Field is required' })} 98 defaultValue={field.value} 99 placeholder="value" 100 /> 101 </Field> 102 <IconButton 103 className={styles.removeButton} 104 tooltip="Remove matcher" 105 name={'trash-alt'} 106 onClick={() => remove(index)} 107 > 108 Remove 109 </IconButton> 110 </HorizontalGroup> 111 ); 112 })} 113 </div> 114 <Button 115 className={styles.addMatcherBtn} 116 icon="plus" 117 onClick={() => append(emptyArrayFieldMatcher)} 118 variant="secondary" 119 type="button" 120 > 121 Add matcher 122 </Button> 123 </> 124 )} 125 </FieldArray> 126 <Field label="Contact point"> 127 {/* @ts-ignore-check: react-hook-form made me do this */} 128 <InputControl 129 render={({ field: { onChange, ref, ...field } }) => ( 130 <Select 131 aria-label="Contact point" 132 {...field} 133 className={formStyles.input} 134 onChange={(value) => onChange(mapSelectValueToString(value))} 135 options={receivers} 136 menuShouldPortal 137 /> 138 )} 139 control={control} 140 name="receiver" 141 /> 142 </Field> 143 <Field label="Continue matching subsequent sibling nodes"> 144 <Switch id="continue-toggle" {...register('continue')} /> 145 </Field> 146 <Field label="Override grouping"> 147 <Switch 148 id="override-grouping-toggle" 149 value={overrideGrouping} 150 onChange={() => setOverrideGrouping((overrideGrouping) => !overrideGrouping)} 151 /> 152 </Field> 153 {overrideGrouping && ( 154 <Field label="Group by" description="Group alerts when you receive a notification based on labels."> 155 <InputControl 156 render={({ field: { onChange, ref, ...field } }) => ( 157 <MultiSelect 158 aria-label="Group by" 159 menuShouldPortal 160 {...field} 161 allowCustomValue 162 className={formStyles.input} 163 onCreateOption={(opt: string) => { 164 setGroupByOptions((opts) => [...opts, stringToSelectableValue(opt)]); 165 166 // @ts-ignore-check: react-hook-form made me do this 167 setValue('groupBy', [...field.value, opt]); 168 }} 169 onChange={(value) => onChange(mapMultiSelectValueToStrings(value))} 170 options={groupByOptions} 171 /> 172 )} 173 control={control} 174 name="groupBy" 175 /> 176 </Field> 177 )} 178 <Field label="Override general timings"> 179 <Switch 180 id="override-timings-toggle" 181 value={overrideTimings} 182 onChange={() => setOverrideTimings((overrideTimings) => !overrideTimings)} 183 /> 184 </Field> 185 {overrideTimings && ( 186 <> 187 <Field 188 label="Group wait" 189 description="The waiting time until the initial notification is sent for a new group created by an incoming alert." 190 invalid={!!errors.groupWaitValue} 191 error={errors.groupWaitValue?.message} 192 > 193 <> 194 <div className={cx(formStyles.container, formStyles.timingContainer)}> 195 <InputControl 196 render={({ field, fieldState: { invalid } }) => ( 197 <Input 198 {...field} 199 className={formStyles.smallInput} 200 invalid={invalid} 201 placeholder="Time" 202 aria-label="Group wait value" 203 /> 204 )} 205 control={control} 206 name="groupWaitValue" 207 rules={{ 208 validate: optionalPositiveInteger, 209 }} 210 /> 211 <InputControl 212 render={({ field: { onChange, ref, ...field } }) => ( 213 <Select 214 menuShouldPortal 215 {...field} 216 className={formStyles.input} 217 onChange={(value) => onChange(mapSelectValueToString(value))} 218 options={timeOptions} 219 aria-label="Group wait type" 220 /> 221 )} 222 control={control} 223 name="groupWaitValueType" 224 /> 225 </div> 226 </> 227 </Field> 228 <Field 229 label="Group interval" 230 description="The waiting time to send a batch of new alerts for that group after the first notification was sent." 231 invalid={!!errors.groupIntervalValue} 232 error={errors.groupIntervalValue?.message} 233 > 234 <> 235 <div className={cx(formStyles.container, formStyles.timingContainer)}> 236 <InputControl 237 render={({ field, fieldState: { invalid } }) => ( 238 <Input 239 {...field} 240 className={formStyles.smallInput} 241 invalid={invalid} 242 placeholder="Time" 243 aria-label="Group interval value" 244 /> 245 )} 246 control={control} 247 name="groupIntervalValue" 248 rules={{ 249 validate: optionalPositiveInteger, 250 }} 251 /> 252 <InputControl 253 render={({ field: { onChange, ref, ...field } }) => ( 254 <Select 255 menuShouldPortal 256 {...field} 257 className={formStyles.input} 258 onChange={(value) => onChange(mapSelectValueToString(value))} 259 options={timeOptions} 260 aria-label="Group interval type" 261 /> 262 )} 263 control={control} 264 name="groupIntervalValueType" 265 /> 266 </div> 267 </> 268 </Field> 269 <Field 270 label="Repeat interval" 271 description="The waiting time to resend an alert after they have successfully been sent." 272 invalid={!!errors.repeatIntervalValue} 273 error={errors.repeatIntervalValue?.message} 274 > 275 <> 276 <div className={cx(formStyles.container, formStyles.timingContainer)}> 277 <InputControl 278 render={({ field, fieldState: { invalid } }) => ( 279 <Input 280 {...field} 281 className={formStyles.smallInput} 282 invalid={invalid} 283 placeholder="Time" 284 aria-label="Repeat interval value" 285 /> 286 )} 287 control={control} 288 name="repeatIntervalValue" 289 rules={{ 290 validate: optionalPositiveInteger, 291 }} 292 /> 293 <InputControl 294 render={({ field: { onChange, ref, ...field } }) => ( 295 <Select 296 menuShouldPortal 297 {...field} 298 className={formStyles.input} 299 menuPlacement="top" 300 onChange={(value) => onChange(mapSelectValueToString(value))} 301 options={timeOptions} 302 aria-label="Repeat interval type" 303 /> 304 )} 305 control={control} 306 name="repeatIntervalValueType" 307 /> 308 </div> 309 </> 310 </Field> 311 </> 312 )} 313 <div className={styles.buttonGroup}> 314 <Button type="submit">Save policy</Button> 315 <Button onClick={onCancel} fill="outline" type="button" variant="secondary"> 316 Cancel 317 </Button> 318 </div> 319 </> 320 )} 321 </Form> 322 ); 323}; 324 325const getStyles = (theme: GrafanaTheme2) => { 326 const commonSpacing = theme.spacing(3.5); 327 328 return { 329 addMatcherBtn: css` 330 margin-bottom: ${commonSpacing}; 331 `, 332 matchersContainer: css` 333 background-color: ${theme.colors.background.secondary}; 334 margin: ${theme.spacing(1, 0)}; 335 padding: ${theme.spacing(1, 4.6, 1, 1.5)}; 336 width: fit-content; 337 `, 338 matchersOperator: css` 339 min-width: 140px; 340 `, 341 nestedPolicies: css` 342 margin-top: ${commonSpacing}; 343 `, 344 removeButton: css` 345 margin-left: ${theme.spacing(1)}; 346 margin-top: ${theme.spacing(2.5)}; 347 `, 348 buttonGroup: css` 349 margin: ${theme.spacing(6)} 0 ${commonSpacing}; 350 351 & > * + * { 352 margin-left: ${theme.spacing(1.5)}; 353 } 354 `, 355 }; 356}; 357