Openstatus www.openstatus.dev
at main 981 lines 40 kB view raw
1"use client"; 2 3import { Link } from "@/components/common/link"; 4import { 5 FormCard, 6 FormCardContent, 7 FormCardDescription, 8 FormCardFooter, 9 FormCardFooterInfo, 10 FormCardHeader, 11 FormCardSeparator, 12 FormCardTitle, 13} from "@/components/forms/form-card"; 14import { 15 AlertDialog, 16 AlertDialogAction, 17 AlertDialogCancel, 18 AlertDialogContent, 19 AlertDialogDescription, 20 AlertDialogFooter, 21 AlertDialogHeader, 22 AlertDialogTitle, 23} from "@/components/ui/alert-dialog"; 24import { Button } from "@/components/ui/button"; 25import { 26 FormControl, 27 FormDescription, 28 FormField, 29 FormItem, 30 FormLabel, 31 FormMessage, 32} from "@/components/ui/form"; 33import { Form } from "@/components/ui/form"; 34import { Input } from "@/components/ui/input"; 35import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 36import { 37 Select, 38 SelectContent, 39 SelectItem, 40 SelectTrigger, 41 SelectValue, 42} from "@/components/ui/select"; 43import { Switch } from "@/components/ui/switch"; 44import { Textarea } from "@/components/ui/textarea"; 45import { 46 Tooltip, 47 TooltipContent, 48 TooltipTrigger, 49} from "@/components/ui/tooltip"; 50import { cn } from "@/lib/utils"; 51import { zodResolver } from "@hookform/resolvers/zod"; 52import { 53 dnsRecords, 54 headerAssertion, 55 jsonBodyAssertion, 56 numberCompareDictionary, 57 recordAssertion, 58 recordCompareDictionary, 59 statusAssertion, 60 stringCompareDictionary, 61 textBodyAssertion, 62} from "@openstatus/assertions"; 63import { monitorMethods } from "@openstatus/db/src/schema"; 64import { isTRPCClientError } from "@trpc/client"; 65import { Globe, Network, Plus, Server, X } from "lucide-react"; 66import { useEffect, useState, useTransition } from "react"; 67import { useForm } from "react-hook-form"; 68import { toast } from "sonner"; 69import { z } from "zod"; 70 71const TYPES = ["http", "tcp", "dns"] as const; 72const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; 73const DNS_ASSERTION_TYPES = dnsRecords; 74 75const schema = z.object({ 76 name: z.string().min(1, "Name is required"), 77 type: z.enum(TYPES), 78 method: z.enum(monitorMethods), 79 url: z.string().min(1, "URL is required"), 80 headers: z.array( 81 z.object({ 82 key: z.string(), 83 value: z.string(), 84 }), 85 ), 86 active: z.boolean().optional().prefault(true), 87 assertions: z.array( 88 z.discriminatedUnion("type", [ 89 statusAssertion, 90 headerAssertion, 91 textBodyAssertion, 92 jsonBodyAssertion, 93 recordAssertion, 94 ]), 95 ), 96 body: z.string().optional(), 97 skipCheck: z.boolean().optional().prefault(false), 98 saveCheck: z.boolean().optional().prefault(false), 99}); 100 101type FormValues = z.input<typeof schema>; 102 103export function FormGeneral({ 104 defaultValues, 105 disabled, 106 onSubmit, 107 ...props 108}: Omit<React.ComponentProps<"form">, "onSubmit"> & { 109 defaultValues?: FormValues; 110 onSubmit: (values: FormValues) => Promise<void>; 111 disabled?: boolean; 112}) { 113 const [error, setError] = useState<string | null>(null); 114 const form = useForm<FormValues>({ 115 resolver: zodResolver(schema), 116 defaultValues: defaultValues ?? { 117 active: true, 118 name: "", 119 type: undefined, 120 method: "GET", 121 url: "", 122 headers: [], 123 body: "", 124 assertions: [], 125 skipCheck: false, 126 saveCheck: false, 127 }, 128 }); 129 const [isPending, startTransition] = useTransition(); 130 const watchType = form.watch("type"); 131 const watchMethod = form.watch("method"); 132 133 useEffect(() => { 134 // NOTE: reset form when type changes 135 if (watchType && !defaultValues) { 136 form.setValue("assertions", []); 137 form.setValue("body", ""); 138 form.setValue("headers", []); 139 form.setValue("method", "GET"); 140 form.setValue("url", ""); 141 } 142 }, [watchType, defaultValues, form]); 143 144 function submitAction(values: FormValues) { 145 console.log("submitAction", values); 146 if (isPending || disabled) return; 147 148 // Validate assertions based on type 149 for (let i = 0; i < values.assertions.length; i++) { 150 const assertion = values.assertions[i]; 151 152 if (assertion.type === "status") { 153 if (typeof assertion.target !== "number" || assertion.target <= 0) { 154 form.setError(`assertions.${i}.target`, { 155 message: "Status target must be a positive number", 156 }); 157 return; 158 } 159 } else if (assertion.type === "header") { 160 if (!assertion.key || assertion.key.trim() === "") { 161 form.setError(`assertions.${i}.key`, { 162 message: "Header key is required", 163 }); 164 return; 165 } 166 if (!assertion.target || assertion.target.trim() === "") { 167 form.setError(`assertions.${i}.target`, { 168 message: "Header target is required", 169 }); 170 return; 171 } 172 } else if (assertion.type === "textBody") { 173 if (!assertion.target || assertion.target.trim() === "") { 174 form.setError(`assertions.${i}.target`, { 175 message: "Body target is required", 176 }); 177 return; 178 } 179 } else if (assertion.type === "dnsRecord") { 180 if (!assertion.key || assertion.key.trim() === "") { 181 form.setError(`assertions.${i}.key`, { 182 message: "DNS record key is required", 183 }); 184 return; 185 } 186 if (!assertion.target || assertion.target.trim() === "") { 187 form.setError(`assertions.${i}.target`, { 188 message: "DNS record target is required", 189 }); 190 return; 191 } 192 } 193 } 194 195 startTransition(async () => { 196 try { 197 const promise = onSubmit(values); 198 toast.promise(promise, { 199 loading: "Saving...", 200 success: "Saved", 201 error: (error) => { 202 if (isTRPCClientError(error)) { 203 setError(error.message); 204 return error.message; 205 } 206 return "Failed to save"; 207 }, 208 }); 209 await promise; 210 } catch (error) { 211 console.error(error); 212 } 213 }); 214 } 215 216 return ( 217 <Form {...form}> 218 <form onSubmit={form.handleSubmit(submitAction)} {...props}> 219 <FormCard> 220 <FormCardHeader> 221 <FormCardTitle>Monitor Configuration</FormCardTitle> 222 <FormCardDescription> 223 Configure your monitor settings and endpoints. 224 </FormCardDescription> 225 </FormCardHeader> 226 <FormCardContent className="grid gap-4 sm:grid-cols-3"> 227 <FormField 228 control={form.control} 229 name="name" 230 render={({ field }) => ( 231 <FormItem className="sm:col-span-2"> 232 <FormLabel>Name</FormLabel> 233 <FormControl> 234 <Input placeholder="OpenStatus API" {...field} /> 235 </FormControl> 236 <FormMessage /> 237 <FormDescription> 238 Displayed on the status page. 239 </FormDescription> 240 </FormItem> 241 )} 242 /> 243 <FormField 244 control={form.control} 245 name="active" 246 render={({ field }) => ( 247 <FormItem className="flex flex-row items-center"> 248 <FormLabel>Active</FormLabel> 249 <FormControl> 250 <Switch 251 checked={field.value} 252 onCheckedChange={field.onChange} 253 /> 254 </FormControl> 255 </FormItem> 256 )} 257 /> 258 </FormCardContent> 259 <FormCardSeparator /> 260 <FormCardContent> 261 <FormField 262 control={form.control} 263 name="type" 264 render={({ field }) => ( 265 <FormItem> 266 <FormLabel>Monitoring Type</FormLabel> 267 <FormControl> 268 <RadioGroup 269 onValueChange={field.onChange} 270 defaultValue={field.value} 271 className="grid grid-cols-2 gap-4 sm:grid-cols-4" 272 disabled={!!defaultValues?.type} 273 > 274 {[ 275 { value: "http", icon: Globe, label: "HTTP" }, 276 { value: "tcp", icon: Network, label: "TCP" }, 277 { value: "dns", icon: Server, label: "DNS" }, 278 ].map((type) => { 279 return ( 280 <Tooltip key={type.value}> 281 <TooltipTrigger asChild> 282 <FormItem 283 className={cn( 284 "relative flex cursor-pointer flex-row items-center gap-3 rounded-md border border-input px-2 py-3 text-center shadow-xs outline-none transition-[color,box-shadow] has-aria-[invalid=true]:border-destructive has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50", 285 defaultValues && 286 defaultValues.type !== type.value && 287 "pointer-events-none opacity-50", 288 )} 289 > 290 <FormControl> 291 <RadioGroupItem 292 value={type.value} 293 className="sr-only" 294 disabled={!!defaultValues?.type} 295 /> 296 </FormControl> 297 <type.icon 298 className="shrink-0 text-muted-foreground" 299 size={16} 300 aria-hidden="true" 301 /> 302 <FormLabel className="cursor-pointer font-medium text-foreground text-xs leading-none after:absolute after:inset-0"> 303 {type.label} 304 </FormLabel> 305 </FormItem> 306 </TooltipTrigger> 307 <TooltipContent> 308 Monitor type cannot be changed after creation. 309 </TooltipContent> 310 </Tooltip> 311 ); 312 })} 313 <div 314 className={cn( 315 "col-span-1 self-end text-muted-foreground text-xs sm:place-self-end", 316 )} 317 > 318 Missing a type?{" "} 319 <a href="mailto:ping@openstatus.dev">Contact us</a> 320 </div> 321 </RadioGroup> 322 </FormControl> 323 <FormMessage /> 324 </FormItem> 325 )} 326 /> 327 </FormCardContent> 328 {watchType ? <FormCardSeparator /> : null} 329 {watchType === "http" && ( 330 <> 331 <FormCardContent className="grid grid-cols-4 gap-4"> 332 <div className="col-span-1"> 333 <FormField 334 control={form.control} 335 name="method" 336 render={({ field }) => ( 337 <FormItem> 338 <FormLabel>Method</FormLabel> 339 <Select 340 onValueChange={field.onChange} 341 defaultValue={field.value} 342 > 343 <FormControl> 344 <SelectTrigger className="w-full"> 345 <SelectValue placeholder="Select a method" /> 346 </SelectTrigger> 347 </FormControl> 348 <SelectContent> 349 {monitorMethods.map((method) => ( 350 <SelectItem key={method} value={method}> 351 {method} 352 </SelectItem> 353 ))} 354 </SelectContent> 355 </Select> 356 <FormMessage /> 357 </FormItem> 358 )} 359 /> 360 </div> 361 <div className="col-span-3"> 362 <FormField 363 control={form.control} 364 name="url" 365 render={({ field }) => ( 366 <FormItem> 367 <FormLabel>URL</FormLabel> 368 <FormControl> 369 <Input 370 placeholder="https://openstatus.dev" 371 type="url" 372 {...field} 373 /> 374 </FormControl> 375 <FormMessage /> 376 </FormItem> 377 )} 378 /> 379 </div> 380 <FormField 381 control={form.control} 382 name="headers" 383 render={({ field }) => ( 384 <FormItem className="col-span-full"> 385 <FormLabel>Request Headers</FormLabel> 386 {field.value.map((header, index) => ( 387 <div key={index} className="grid gap-2 sm:grid-cols-5"> 388 <Input 389 placeholder="Key" 390 className="col-span-2" 391 value={header.key} 392 onChange={(e) => { 393 const newHeaders = [...field.value]; 394 newHeaders[index] = { 395 ...newHeaders[index], 396 key: e.target.value, 397 }; 398 field.onChange(newHeaders); 399 }} 400 /> 401 <Input 402 placeholder="Value" 403 className="col-span-2" 404 value={header.value} 405 onChange={(e) => { 406 const newHeaders = [...field.value]; 407 newHeaders[index] = { 408 ...newHeaders[index], 409 value: e.target.value, 410 }; 411 field.onChange(newHeaders); 412 }} 413 /> 414 <Button 415 size="icon" 416 variant="ghost" 417 onClick={() => { 418 const newHeaders = field.value.filter( 419 (_, i) => i !== index, 420 ); 421 field.onChange(newHeaders); 422 }} 423 > 424 <X /> 425 </Button> 426 </div> 427 ))} 428 <div> 429 <Button 430 size="sm" 431 variant="outline" 432 type="button" 433 onClick={() => { 434 field.onChange([ 435 ...field.value, 436 { key: "", value: "" }, 437 ]); 438 }} 439 > 440 <Plus /> 441 Add Header 442 </Button> 443 </div> 444 <FormMessage /> 445 </FormItem> 446 )} 447 /> 448 {["POST", "PUT", "PATCH", "DELETE"].includes(watchMethod) && ( 449 <FormField 450 control={form.control} 451 name="body" 452 render={({ field }) => ( 453 <FormItem className="col-span-full"> 454 <FormLabel>Body</FormLabel> 455 <FormControl> 456 <Textarea {...field} /> 457 </FormControl> 458 <FormDescription>Write your payload</FormDescription> 459 <FormMessage /> 460 </FormItem> 461 )} 462 /> 463 )} 464 </FormCardContent> 465 <FormCardSeparator /> 466 <FormCardContent> 467 <FormField 468 control={form.control} 469 name="assertions" 470 render={({ field }) => ( 471 <FormItem className="col-span-full"> 472 <FormLabel>Assertions</FormLabel> 473 <FormDescription> 474 Validate the response to ensure your service is working 475 as expected. <br /> 476 Add body, header, or status assertions. 477 </FormDescription> 478 {field.value.map((assertion, index) => ( 479 <div key={index} className="grid gap-2 sm:grid-cols-6"> 480 <FormField 481 control={form.control} 482 name={`assertions.${index}.type`} 483 render={({ field }) => ( 484 <FormItem> 485 <Select 486 value={field.value} 487 onValueChange={field.onChange} 488 disabled={true} 489 > 490 <SelectTrigger 491 aria-invalid={ 492 !!form.formState.errors.assertions?.[ 493 index 494 ]?.type 495 } 496 className="w-full" 497 > 498 <SelectValue placeholder="Select type" /> 499 </SelectTrigger> 500 <SelectContent> 501 {HTTP_ASSERTION_TYPES.map((type) => ( 502 <SelectItem key={type} value={type}> 503 {type} 504 </SelectItem> 505 ))} 506 </SelectContent> 507 </Select> 508 </FormItem> 509 )} 510 /> 511 <FormField 512 control={form.control} 513 name={`assertions.${index}.compare`} 514 render={({ field }) => ( 515 <FormItem> 516 <Select 517 value={field.value} 518 onValueChange={field.onChange} 519 > 520 <SelectTrigger className="w-full min-w-16"> 521 <span className="truncate"> 522 <SelectValue placeholder="Select compare" /> 523 </span> 524 </SelectTrigger> 525 <SelectContent> 526 {assertion.type === "status" 527 ? Object.entries( 528 numberCompareDictionary, 529 ).map(([key, value]) => ( 530 <SelectItem key={key} value={key}> 531 {value} 532 </SelectItem> 533 )) 534 : Object.entries( 535 stringCompareDictionary, 536 ).map(([key, value]) => ( 537 <SelectItem key={key} value={key}> 538 {value} 539 </SelectItem> 540 ))} 541 </SelectContent> 542 </Select> 543 <FormMessage /> 544 </FormItem> 545 )} 546 /> 547 {assertion.type === "header" && ( 548 <FormField 549 control={form.control} 550 name={`assertions.${index}.key`} 551 render={({ field }) => ( 552 <FormItem> 553 <Input 554 placeholder="Header key" 555 className="w-full" 556 {...field} 557 value={field.value as string} 558 /> 559 <FormMessage /> 560 </FormItem> 561 )} 562 /> 563 )} 564 <FormField 565 control={form.control} 566 name={`assertions.${index}.target`} 567 render={({ field }) => ( 568 <FormItem> 569 <Input 570 placeholder="Target value" 571 className="w-full" 572 type={ 573 assertion.type === "status" 574 ? "number" 575 : "text" 576 } 577 {...field} 578 value={field.value?.toString() || ""} 579 onChange={(e) => { 580 const value = 581 assertion.type === "status" 582 ? Number.parseInt(e.target.value) || 0 583 : e.target.value; 584 field.onChange(value); 585 }} 586 /> 587 <FormMessage /> 588 </FormItem> 589 )} 590 /> 591 <Button 592 size="icon" 593 variant="ghost" 594 type="button" 595 onClick={() => { 596 const newAssertions = field.value.filter( 597 (_, i) => i !== index, 598 ); 599 field.onChange(newAssertions); 600 }} 601 > 602 <X /> 603 </Button> 604 </div> 605 ))} 606 <div className="flex flex-wrap gap-2"> 607 <Button 608 size="sm" 609 variant="outline" 610 type="button" 611 onClick={() => { 612 const currentAssertions = 613 form.getValues("assertions"); 614 field.onChange([ 615 ...currentAssertions, 616 { 617 type: "status", 618 version: "v1", 619 compare: "eq", 620 target: 200, 621 }, 622 ]); 623 }} 624 > 625 <Plus /> 626 Add Status Assertion 627 </Button> 628 <Button 629 size="sm" 630 variant="outline" 631 type="button" 632 onClick={() => { 633 const currentAssertions = 634 form.getValues("assertions"); 635 field.onChange([ 636 ...currentAssertions, 637 { 638 type: "header", 639 version: "v1", 640 compare: "eq", 641 key: "", 642 target: "", 643 }, 644 ]); 645 }} 646 > 647 <Plus /> 648 Add Header Assertion 649 </Button> 650 <Button 651 size="sm" 652 variant="outline" 653 type="button" 654 onClick={() => { 655 const currentAssertions = 656 form.getValues("assertions"); 657 field.onChange([ 658 ...currentAssertions, 659 { 660 type: "textBody", 661 version: "v1", 662 compare: "eq", 663 target: "", 664 }, 665 ]); 666 }} 667 > 668 <Plus /> 669 Add Body Assertion 670 </Button> 671 </div> 672 <FormMessage /> 673 </FormItem> 674 )} 675 /> 676 </FormCardContent> 677 </> 678 )} 679 {watchType === "tcp" && ( 680 <FormCardContent className="grid gap-4 sm:grid-cols-3"> 681 <FormField 682 control={form.control} 683 name="url" 684 render={({ field }) => ( 685 <FormItem className="sm:col-span-2"> 686 <FormLabel>Host:Port</FormLabel> 687 <FormControl> 688 <Input placeholder="127.0.0.0.1:8080" {...field} /> 689 </FormControl> 690 <FormMessage /> 691 <FormDescription> 692 The input supports both IPv4 addresses and IPv6 addresses. 693 </FormDescription> 694 </FormItem> 695 )} 696 /> 697 <div className="col-span-full text-muted-foreground text-sm"> 698 Examples: 699 <ul className="list-inside list-disc"> 700 <li> 701 Domain:{" "} 702 <span className="font-mono text-foreground"> 703 openstatus.dev:443 704 </span> 705 </li> 706 <li> 707 IPv4:{" "} 708 <span className="font-mono text-foreground"> 709 192.168.1.1:443 710 </span> 711 </li> 712 <li> 713 IPv6:{" "} 714 <span className="font-mono text-foreground"> 715 [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 716 </span> 717 </li> 718 </ul> 719 </div> 720 </FormCardContent> 721 )} 722 {watchType === "dns" && ( 723 <> 724 <FormCardContent className="grid gap-4 sm:grid-cols-3"> 725 <FormField 726 control={form.control} 727 name="url" 728 render={({ field }) => ( 729 <FormItem className="sm:col-span-2"> 730 <FormLabel>URI</FormLabel> 731 <FormControl> 732 <Input placeholder="openstatus.dev" {...field} /> 733 </FormControl> 734 <FormMessage /> 735 <FormDescription> 736 The input supports both domain names and URIs. 737 </FormDescription> 738 </FormItem> 739 )} 740 /> 741 </FormCardContent> 742 <FormCardSeparator /> 743 <FormCardContent> 744 <FormField 745 control={form.control} 746 name="assertions" 747 render={({ field }) => ( 748 <FormItem className="col-span-full"> 749 <FormLabel>Assertions</FormLabel> 750 <FormDescription> 751 Validate the response to ensure your service is working 752 as expected. <br /> 753 Add DNS record assertions. 754 </FormDescription> 755 {field.value.map((assertion, index) => ( 756 <div key={index} className="grid gap-2 sm:grid-cols-6"> 757 <FormField 758 control={form.control} 759 name={`assertions.${index}.type`} 760 defaultValue={"dnsRecord"} 761 render={({ field }) => ( 762 <FormItem className="hidden"> 763 <Select 764 value={field.value} 765 onValueChange={field.onChange} 766 disabled 767 > 768 <SelectTrigger className="w-full"> 769 <SelectValue placeholder="Select type" /> 770 </SelectTrigger> 771 </Select> 772 </FormItem> 773 )} 774 /> 775 <FormField 776 control={form.control} 777 name={`assertions.${index}.key`} 778 render={({ field }) => ( 779 <FormItem> 780 <Select 781 value={field.value as string} 782 onValueChange={field.onChange} 783 > 784 <SelectTrigger 785 aria-invalid={ 786 !!form.formState.errors.assertions?.[ 787 index 788 ]?.type 789 } 790 className="w-full" 791 > 792 <SelectValue placeholder="Select type" /> 793 </SelectTrigger> 794 <SelectContent> 795 {DNS_ASSERTION_TYPES.map((type) => ( 796 <SelectItem key={type} value={type}> 797 {type} 798 </SelectItem> 799 ))} 800 </SelectContent> 801 </Select> 802 </FormItem> 803 )} 804 /> 805 <FormField 806 control={form.control} 807 name={`assertions.${index}.compare`} 808 render={({ field }) => ( 809 <FormItem> 810 <Select 811 value={field.value} 812 onValueChange={field.onChange} 813 > 814 <SelectTrigger className="w-full min-w-16"> 815 <span className="truncate"> 816 <SelectValue placeholder="Select compare" /> 817 </span> 818 </SelectTrigger> 819 <SelectContent> 820 {Object.entries( 821 recordCompareDictionary, 822 ).map(([key, value]) => ( 823 <SelectItem key={key} value={key}> 824 {value} 825 </SelectItem> 826 ))} 827 </SelectContent> 828 </Select> 829 <FormMessage /> 830 </FormItem> 831 )} 832 /> 833 {assertion.type === "header" && ( 834 <FormField 835 control={form.control} 836 name={`assertions.${index}.key`} 837 render={({ field }) => ( 838 <FormItem> 839 <Input 840 placeholder="Header key" 841 className="w-full" 842 {...field} 843 value={field.value as string} 844 /> 845 <FormMessage /> 846 </FormItem> 847 )} 848 /> 849 )} 850 <FormField 851 control={form.control} 852 name={`assertions.${index}.target`} 853 render={({ field }) => ( 854 <FormItem> 855 <Input 856 placeholder="Target value" 857 className="w-full" 858 type={ 859 assertion.type === "status" 860 ? "number" 861 : "text" 862 } 863 {...field} 864 value={field.value?.toString() || ""} 865 onChange={(e) => { 866 const value = 867 assertion.type === "status" 868 ? Number.parseInt(e.target.value) || 0 869 : e.target.value; 870 field.onChange(value); 871 }} 872 /> 873 <FormMessage /> 874 </FormItem> 875 )} 876 /> 877 <Button 878 size="icon" 879 variant="ghost" 880 type="button" 881 onClick={() => { 882 const newAssertions = field.value.filter( 883 (_, i) => i !== index, 884 ); 885 field.onChange(newAssertions); 886 }} 887 > 888 <X /> 889 </Button> 890 </div> 891 ))} 892 <div className="flex flex-wrap gap-2"> 893 <Button 894 size="sm" 895 variant="outline" 896 type="button" 897 onClick={() => { 898 const currentAssertions = 899 form.getValues("assertions"); 900 field.onChange([ 901 ...currentAssertions, 902 { 903 type: "dnsRecord", 904 version: "v1", 905 compare: "eq", 906 key: "A", 907 target: "", 908 }, 909 ]); 910 }} 911 > 912 <Plus /> 913 Add DNS Record Assertion 914 </Button> 915 </div> 916 <FormMessage /> 917 </FormItem> 918 )} 919 /> 920 </FormCardContent> 921 </> 922 )} 923 <FormCardFooter> 924 <FormCardFooterInfo> 925 Learn more about{" "} 926 <Link 927 href="https://docs.openstatus.dev/tutorial/how-to-create-monitor/" 928 rel="noreferrer" 929 target="_blank" 930 > 931 Monitor Type 932 </Link>{" "} 933 and{" "} 934 <Link 935 href="https://docs.openstatus.dev/tutorial/how-to-create-monitor/" 936 rel="noreferrer" 937 target="_blank" 938 > 939 Assertions 940 </Link> 941 . We test your endpoint before saving the monitor. 942 </FormCardFooterInfo> 943 <Button type="submit" disabled={isPending || disabled}> 944 {isPending ? "Submitting..." : "Submit"} 945 </Button> 946 </FormCardFooter> 947 </FormCard> 948 <AlertDialog open={!!error} onOpenChange={() => setError(null)}> 949 <AlertDialogContent> 950 <AlertDialogHeader> 951 <AlertDialogTitle>Still save?</AlertDialogTitle> 952 <AlertDialogDescription> 953 It seems like the endpoint is not reachable or the assertions 954 failed. Do you want to save the monitor anyway? 955 </AlertDialogDescription> 956 </AlertDialogHeader> 957 <div className="max-h-48 overflow-auto whitespace-pre rounded-md border border-destructive/20 bg-destructive/10 p-2"> 958 <p className="font-mono text-destructive text-sm">{error}</p> 959 </div> 960 <AlertDialogFooter> 961 <AlertDialogCancel type="button">Cancel</AlertDialogCancel> 962 <AlertDialogAction 963 type="button" 964 onClick={async (e) => { 965 e.preventDefault(); 966 form.setValue("skipCheck", true); 967 form.handleSubmit(submitAction)(); 968 form.setValue("skipCheck", false); 969 setError(null); 970 }} 971 disabled={isPending} 972 > 973 Save 974 </AlertDialogAction> 975 </AlertDialogFooter> 976 </AlertDialogContent> 977 </AlertDialog> 978 </form> 979 </Form> 980 ); 981}