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.
40 kB ยท 986 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987"use client";
import { zodResolver } from "@hookform/resolvers/zod";import { dnsRecords, headerAssertion, jsonBodyAssertion, numberCompareDictionary, recordAssertion, recordCompareDictionary, statusAssertion, stringCompareDictionary, textBodyAssertion,} from "@openstatus/assertions";import { monitorMethods } from "@openstatus/db/src/schema";import { Globe, Network, Add, Server, Close } 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 { 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 { Switch } from "@openstatus/ui/components/ui/switch";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"] as const;const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const;const DNS_ASSERTION_TYPES = dnsRecords;
const schema = z.object({ name: z.string().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(), 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: [], skipCheck: false, saveCheck: false, }, }); const [isPending, startTransition] = useTransition(); const watchType = form.watch("type"); const watchMethod = form.watch("method");
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 for your monitor. This will be used to identify the monitor in the dashboard. </FormDescription> </FormItem> )} /> <FormField control={form.control} name="active" render={({ field }) => ( <FormItem className="flex flex-row items-center"> <FormLabel>Active</FormLabel> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </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-4" disabled={!!defaultValues?.type} > {[ { value: "http", icon: Globe, label: "HTTP" }, { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, ].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 === "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="https://www.openstatus.dev/docs/tutorial/how-to-create-monitor/" rel="noreferrer" target="_blank" > Monitor Type </Link>{" "} and{" "} <Link href="https://www.openstatus.dev/docs/tutorial/how-to-create-monitor/" 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> );}