Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
48 kB ยท 1198 lines
TSX
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199"use client";
import { zodResolver } from "@hookform/resolvers/zod";import { dnsRecords, headerAssertion, jsonBodyAssertion, numberCompareDictionary, recordAssertion, recordCompareDictionary, statusAssertion, stringCompareDictionary, textBodyAssertion,} from "@openstatus/assertions";import { grpcTlsModes, monitorMethods,} from "@openstatus/db/src/schema/monitors/constants";import { Add, Api, Close, Globe, Network, Server, Speed,} from "@openstatus/icons";import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from "@openstatus/ui/components/ui/alert-dialog";import { Button } from "@openstatus/ui/components/ui/button";import { Checkbox } from "@openstatus/ui/components/ui/checkbox";import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from "@openstatus/ui/components/ui/form";import { Input } from "@openstatus/ui/components/ui/input";import { RadioGroup, RadioGroupItem,} from "@openstatus/ui/components/ui/radio-group";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@openstatus/ui/components/ui/select";import { Textarea } from "@openstatus/ui/components/ui/textarea";import { Tooltip, TooltipContent, TooltipTrigger,} from "@openstatus/ui/components/ui/tooltip";import { cn } from "@openstatus/ui/lib/utils";import { isTRPCClientError } from "@trpc/client";import { useEffect, useState, useTransition } from "react";import { useForm } from "react-hook-form";import { toast } from "sonner";import { z } from "zod";
import { Link } from "@/components/common/link";import { FormCard, FormCardContent, FormCardDescription, FormCardFooter, FormCardFooterInfo, FormCardHeader, FormCardSeparator, FormCardTitle,} from "@/components/forms/form-card";
const TYPES = ["http", "tcp", "dns", "icmp", "grpc"] as const;const GRPC_TLS_LABELS = { tls: "TLS (verify certificate)", tls_insecure: "TLS (skip verification)", plaintext: "Plaintext (h2c)",} as const;const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const;const DNS_ASSERTION_TYPES = dnsRecords;
const schema = z.object({ name: z.string().trim().min(1, "Name is required"), type: z.enum(TYPES), method: z.enum(monitorMethods), url: z.string().min(1, "URL is required"), headers: z.array( z.object({ key: z.string(), value: z.string(), }), ), active: z.boolean().optional().prefault(true), assertions: z.array( z.discriminatedUnion("type", [ statusAssertion, headerAssertion, textBodyAssertion, jsonBodyAssertion, recordAssertion, ]), ), body: z.string().optional(), grpcService: z.string().optional(), grpcTls: z.enum(grpcTlsModes).optional().prefault("tls"), skipCheck: z.boolean().optional().prefault(false), saveCheck: z.boolean().optional().prefault(false),});
type FormValues = z.input<typeof schema>;
export function FormGeneral({ defaultValues, disabled, onSubmit, ...props}: Omit<React.ComponentProps<"form">, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise<void>; disabled?: boolean;}) { const [error, setError] = useState<string | null>(null); const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { active: true, name: "", type: undefined, method: "GET", url: "", headers: [], body: "", assertions: [], grpcService: "", grpcTls: "tls", skipCheck: false, saveCheck: false, }, }); const [isPending, startTransition] = useTransition(); const watchType = form.watch("type"); const watchMethod = form.watch("method"); // Each type has its own reference page; only http and dns support assertions. const referenceUrl = `https://www.openstatus.dev/docs/reference/${watchType ?? "http"}-monitor/`; const hasAssertions = watchType === "http" || watchType === "dns";
useEffect(() => { // NOTE: reset form when type changes if (watchType && !defaultValues) { form.setValue("assertions", []); form.setValue("body", ""); form.setValue("headers", []); form.setValue("method", "GET"); form.setValue("url", ""); } }, [watchType, defaultValues, form]);
function submitAction(values: FormValues) { console.log("submitAction", values); if (isPending || disabled) return;
// Validate assertions based on type for (let i = 0; i < values.assertions.length; i++) { const assertion = values.assertions[i];
if (assertion.type === "status") { if (typeof assertion.target !== "number" || assertion.target <= 0) { form.setError(`assertions.${i}.target`, { message: "Status target must be a positive number", }); return; } } else if (assertion.type === "header") { if (!assertion.key || assertion.key.trim() === "") { form.setError(`assertions.${i}.key`, { message: "Header key is required", }); return; } if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "Header target is required", }); return; } } else if (assertion.type === "textBody") { if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "Body target is required", }); return; } } else if (assertion.type === "dnsRecord") { if (!assertion.key || assertion.key.trim() === "") { form.setError(`assertions.${i}.key`, { message: "DNS record key is required", }); return; } if (!assertion.target || assertion.target.trim() === "") { form.setError(`assertions.${i}.target`, { message: "DNS record target is required", }); return; } } }
startTransition(async () => { try { const promise = onSubmit(values); toast.promise(promise, { loading: "Saving...", success: "Saved", error: (error) => { if (isTRPCClientError(error)) { setError(error.message); return error.message; } return "Failed to save"; }, }); await promise; } catch (error) { console.error(error); } }); }
return ( <Form {...form}> <form onSubmit={form.handleSubmit(submitAction)} {...props}> <FormCard> <FormCardHeader> <FormCardTitle>Monitor Configuration</FormCardTitle> <FormCardDescription> Configure your monitor settings and endpoints. </FormCardDescription> </FormCardHeader> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="OpenStatus API" {...field} /> </FormControl> <FormMessage /> <FormDescription> Internal name to identify the monitor in the dashboard. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="active" render={({ field }) => ( <FormItem className="sm:pt-5.5"> {/* pt = label height + gap, so the checkbox sits on the input line */} <div className="flex items-center gap-2 sm:h-9"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <FormLabel>Active</FormLabel> </div> <FormDescription>Uncheck to pause checks.</FormDescription> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="type" render={({ field }) => ( <FormItem> <FormLabel>Monitoring Type</FormLabel> <FormControl> <RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="grid grid-cols-2 gap-4 sm:grid-cols-5" disabled={!!defaultValues?.type} > {[ { value: "http", icon: Globe, label: "HTTP" }, { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, { value: "icmp", icon: Speed, label: "ICMP" }, { value: "grpc", icon: Api, label: "gRPC" }, ].map((type) => { return ( <Tooltip key={type.value}> <TooltipTrigger asChild> <FormItem className={cn( "border-input has-aria-[invalid=true]:border-destructive has-data-[state=checked]:border-primary/50 has-focus-visible:border-ring has-focus-visible:ring-ring/50 relative flex cursor-pointer flex-row items-center gap-3 rounded-md border px-2 py-3 text-center shadow-xs transition-[color,box-shadow] outline-none has-focus-visible:ring-[3px]", defaultValues && defaultValues.type !== type.value && "pointer-events-none opacity-50", )} > <FormControl> <RadioGroupItem value={type.value} className="sr-only" disabled={!!defaultValues?.type} /> </FormControl> <type.icon className="text-muted-foreground shrink-0" size={16} aria-hidden="true" /> <FormLabel className="text-foreground cursor-pointer text-xs leading-none font-medium after:absolute after:inset-0"> {type.label} </FormLabel> </FormItem> </TooltipTrigger> <TooltipContent> Monitor type cannot be changed after creation. </TooltipContent> </Tooltip> ); })} <div className={cn( "text-muted-foreground col-span-1 self-end text-xs sm:place-self-end", )} > Missing a type?{" "} <a href="mailto:ping@openstatus.dev">Contact us</a> </div> </RadioGroup> </FormControl> <FormMessage /> </FormItem> )} /> </FormCardContent> {watchType ? <FormCardSeparator /> : null} {watchType === "http" && ( <> <FormCardContent className="grid grid-cols-4 gap-4"> <div className="col-span-1"> <FormField control={form.control} name="method" render={({ field }) => ( <FormItem> <FormLabel>Method</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} > <FormControl> <SelectTrigger className="w-full"> <SelectValue placeholder="Select a method" /> </SelectTrigger> </FormControl> <SelectContent> {monitorMethods.map((method) => ( <SelectItem key={method} value={method}> {method} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div> <div className="col-span-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem> <FormLabel>URL</FormLabel> <FormControl> <Input placeholder="https://openstatus.dev" type="url" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="headers" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Request Headers</FormLabel> {field.value.map((header, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-5"> <Input placeholder="Key" className="col-span-2" value={header.key} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], key: e.target.value, }; field.onChange(newHeaders); }} /> <Input placeholder="Value" className="col-span-2" value={header.value} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], value: e.target.value, }; field.onChange(newHeaders); }} /> <Button size="icon" variant="ghost" onClick={() => { const newHeaders = field.value.filter( (_, i) => i !== index, ); field.onChange(newHeaders); }} > <Close /> </Button> </div> ))} <div> <Button size="sm" variant="outline" type="button" onClick={() => { field.onChange([ ...field.value, { key: "", value: "" }, ]); }} > <Add /> Add Header </Button> </div> <FormMessage /> </FormItem> )} /> {["POST", "PUT", "PATCH", "DELETE"].includes(watchMethod) && ( <FormField control={form.control} name="body" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Body</FormLabel> <FormControl> <Textarea {...field} /> </FormControl> <FormDescription>Write your payload</FormDescription> <FormMessage /> </FormItem> )} /> )} </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="assertions" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Assertions</FormLabel> <FormDescription> Validate the response to ensure your service is working as expected. <br /> Add body, header, or status assertions. </FormDescription> {field.value.map((assertion, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-6"> <FormField control={form.control} name={`assertions.${index}.type`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} disabled={true} > <SelectTrigger aria-invalid={ !!form.formState.errors.assertions?.[ index ]?.type } className="w-full" > <SelectValue placeholder="Select type" /> </SelectTrigger> <SelectContent> {HTTP_ASSERTION_TYPES.map((type) => ( <SelectItem key={type} value={type}> {type} </SelectItem> ))} </SelectContent> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.compare`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} > <SelectTrigger className="w-full min-w-16"> <span className="truncate"> <SelectValue placeholder="Select compare" /> </span> </SelectTrigger> <SelectContent> {assertion.type === "status" ? Object.entries( numberCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> )) : Object.entries( stringCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {assertion.type === "header" && ( <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Input placeholder="Header key" className="w-full" {...field} value={field.value as string} /> <FormMessage /> </FormItem> )} /> )} <FormField control={form.control} name={`assertions.${index}.target`} render={({ field }) => ( <FormItem> <Input placeholder="Target value" className="w-full" type={ assertion.type === "status" ? "number" : "text" } {...field} value={field.value?.toString() || ""} onChange={(e) => { const value = assertion.type === "status" ? Number.parseInt(e.target.value) || 0 : e.target.value; field.onChange(value); }} /> <FormMessage /> </FormItem> )} /> <Button size="icon" variant="ghost" type="button" onClick={() => { const newAssertions = field.value.filter( (_, i) => i !== index, ); field.onChange(newAssertions); }} > <Close /> </Button> </div> ))} <div className="flex flex-wrap gap-2"> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "status", version: "v1", compare: "eq", target: 200, }, ]); }} > <Add /> Add Status Assertion </Button> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "header", version: "v1", compare: "eq", key: "", target: "", }, ]); }} > <Add /> Add Header Assertion </Button> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "textBody", version: "v1", compare: "eq", target: "", }, ]); }} > <Add /> Add Body Assertion </Button> </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </> )} {watchType === "tcp" && ( <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Host:Port</FormLabel> <FormControl> <Input placeholder="127.0.0.0.1:8080" {...field} /> </FormControl> <FormMessage /> <FormDescription> The input supports both IPv4 addresses and IPv6 addresses. </FormDescription> </FormItem> )} /> <div className="text-muted-foreground col-span-full text-sm"> Examples: <ul className="list-inside list-disc"> <li> Domain:{" "} <span className="text-foreground font-mono"> openstatus.dev:443 </span> </li> <li> IPv4:{" "} <span className="text-foreground font-mono"> 192.168.1.1:443 </span> </li> <li> IPv6:{" "} <span className="text-foreground font-mono"> [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443 </span> </li> </ul> </div> </FormCardContent> )} {watchType === "icmp" && ( <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Host</FormLabel> <FormControl> <Input placeholder="1.1.1.1" {...field} /> </FormControl> <FormMessage /> <FormDescription> The host to ping. Supports a domain, IPv4, or IPv6 address (no port). </FormDescription> </FormItem> )} /> <div className="text-muted-foreground col-span-full text-sm"> Examples: <ul className="list-inside list-disc"> <li> Domain:{" "} <span className="text-foreground font-mono"> openstatus.dev </span> </li> <li> IPv4:{" "} <span className="text-foreground font-mono">1.1.1.1</span> </li> <li> IPv6:{" "} <span className="text-foreground font-mono"> 2001:4860:4860::8888 </span> </li> </ul> </div> </FormCardContent> )} {watchType === "grpc" && ( <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>Host:Port</FormLabel> <FormControl> <Input placeholder="api.example.com:443" {...field} /> </FormControl> <FormMessage /> <FormDescription> The gRPC target. A port is required; bracket IPv6 addresses. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="grpcTls" render={({ field }) => ( <FormItem> <FormLabel>TLS</FormLabel> <Select onValueChange={field.onChange} value={field.value ?? "tls"} > <FormControl> <SelectTrigger className="w-full"> <SelectValue /> </SelectTrigger> </FormControl> <SelectContent> {grpcTlsModes.map((mode) => ( <SelectItem key={mode} value={mode}> {GRPC_TLS_LABELS[mode]} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="grpcService" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Service</FormLabel> <FormControl> <Input placeholder="checkout.v1.CheckoutService" {...field} value={field.value ?? ""} /> </FormControl> <FormMessage /> <FormDescription> The service name passed to grpc.health.v1.Health/Check. Leave empty to check overall server health. This is a service name, not a method path: entering grpc.health.v1.Health itself returns SERVICE_UNKNOWN. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="headers" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Metadata</FormLabel> {field.value.map((header, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-5"> <Input placeholder="Key" className="col-span-2" value={header.key} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], key: e.target.value, }; field.onChange(newHeaders); }} /> <Input placeholder="Value" className="col-span-2" value={header.value} onChange={(e) => { const newHeaders = [...field.value]; newHeaders[index] = { ...newHeaders[index], value: e.target.value, }; field.onChange(newHeaders); }} /> <Button size="icon" variant="ghost" onClick={() => { field.onChange( field.value.filter((_, i) => i !== index), ); }} > <Close /> </Button> </div> ))} <div> <Button size="sm" variant="outline" type="button" onClick={() => { field.onChange([ ...field.value, { key: "", value: "" }, ]); }} > <Add /> Add Metadata </Button> </div> <FormDescription> Sent with the health check request, commonly for authentication. </FormDescription> </FormItem> )} /> </FormCardContent> )} {watchType === "dns" && ( <> <FormCardContent className="grid gap-4 sm:grid-cols-3"> <FormField control={form.control} name="url" render={({ field }) => ( <FormItem className="sm:col-span-2"> <FormLabel>URI</FormLabel> <FormControl> <Input placeholder="openstatus.dev" {...field} /> </FormControl> <FormMessage /> <FormDescription> The input supports both domain names and URIs. </FormDescription> </FormItem> )} /> </FormCardContent> <FormCardSeparator /> <FormCardContent> <FormField control={form.control} name="assertions" render={({ field }) => ( <FormItem className="col-span-full"> <FormLabel>Assertions</FormLabel> <FormDescription> Validate the response to ensure your service is working as expected. <br /> Add DNS record assertions. </FormDescription> {field.value.map((assertion, index) => ( <div key={index} className="grid gap-2 sm:grid-cols-6"> <FormField control={form.control} name={`assertions.${index}.type`} defaultValue={"dnsRecord"} render={({ field }) => ( <FormItem className="hidden"> <Select value={field.value} onValueChange={field.onChange} disabled > <SelectTrigger className="w-full"> <SelectValue placeholder="Select type" /> </SelectTrigger> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Select value={field.value as string} onValueChange={field.onChange} > <SelectTrigger aria-invalid={ !!form.formState.errors.assertions?.[ index ]?.type } className="w-full" > <SelectValue placeholder="Select type" /> </SelectTrigger> <SelectContent> {DNS_ASSERTION_TYPES.map((type) => ( <SelectItem key={type} value={type}> {type} </SelectItem> ))} </SelectContent> </Select> </FormItem> )} /> <FormField control={form.control} name={`assertions.${index}.compare`} render={({ field }) => ( <FormItem> <Select value={field.value} onValueChange={field.onChange} > <SelectTrigger className="w-full min-w-16"> <span className="truncate"> <SelectValue placeholder="Select compare" /> </span> </SelectTrigger> <SelectContent> {Object.entries( recordCompareDictionary, ).map(([key, value]) => ( <SelectItem key={key} value={key}> {value} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> {assertion.type === "header" && ( <FormField control={form.control} name={`assertions.${index}.key`} render={({ field }) => ( <FormItem> <Input placeholder="Header key" className="w-full" {...field} value={field.value as string} /> <FormMessage /> </FormItem> )} /> )} <FormField control={form.control} name={`assertions.${index}.target`} render={({ field }) => ( <FormItem> <Input placeholder="Target value" className="w-full" type={ assertion.type === "status" ? "number" : "text" } {...field} value={field.value?.toString() || ""} onChange={(e) => { const value = assertion.type === "status" ? Number.parseInt(e.target.value) || 0 : e.target.value; field.onChange(value); }} /> <FormMessage /> </FormItem> )} /> <Button size="icon" variant="ghost" type="button" onClick={() => { const newAssertions = field.value.filter( (_, i) => i !== index, ); field.onChange(newAssertions); }} > <Close /> </Button> </div> ))} <div className="flex flex-wrap gap-2"> <Button size="sm" variant="outline" type="button" onClick={() => { const currentAssertions = form.getValues("assertions"); field.onChange([ ...currentAssertions, { type: "dnsRecord", version: "v1", compare: "eq", key: "A", target: "", }, ]); }} > <Add /> Add DNS Record Assertion </Button> </div> <FormMessage /> </FormItem> )} /> </FormCardContent> </> )} <FormCardFooter> <FormCardFooterInfo> Learn more about{" "} <Link href={referenceUrl} rel="noreferrer" target="_blank"> Monitor Type </Link> {hasAssertions && ( <> {" "} and{" "} <Link href={`${referenceUrl}#assertions`} rel="noreferrer" target="_blank" > Assertions </Link> </> )} . We test your endpoint before saving the monitor. </FormCardFooterInfo> <Button type="submit" disabled={isPending || disabled}> {isPending ? "Submitting..." : "Submit"} </Button> </FormCardFooter> </FormCard> <AlertDialog open={!!error} onOpenChange={() => setError(null)}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Still save?</AlertDialogTitle> <AlertDialogDescription> It seems like the endpoint is not reachable or the assertions failed. Do you want to save the monitor anyway? </AlertDialogDescription> </AlertDialogHeader> <div className="border-destructive/20 bg-destructive/10 max-h-48 overflow-auto rounded-md border p-2 whitespace-pre"> <p className="text-destructive font-mono text-sm">{error}</p> </div> <AlertDialogFooter> <AlertDialogCancel type="button">Cancel</AlertDialogCancel> <AlertDialogAction type="button" onClick={async (e) => { e.preventDefault(); form.setValue("skipCheck", true); form.handleSubmit(submitAction)(); form.setValue("skipCheck", false); setError(null); }} disabled={isPending} > Save </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </form> </Form> );}