Openstatus
www.openstatus.dev
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}